about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/confirmations_controller.rb19
-rw-r--r--app/controllers/admin/reported_statuses_controller.rb19
-rw-r--r--app/controllers/admin/statuses_controller.rb35
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/follower_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/following_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb16
-rw-r--r--app/controllers/api/v1/push/subscriptions_controller.rb56
-rw-r--r--app/controllers/api/v1/statuses/pins_controller.rb4
-rw-r--r--app/controllers/api/v1/statuses_controller.rb10
-rw-r--r--app/controllers/api/v1/timelines/direct_controller.rb15
-rw-r--r--app/controllers/api/v1/trends_controller.rb17
-rw-r--r--app/controllers/api/v2/search_controller.rb8
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb11
-rw-r--r--app/controllers/application_controller.rb6
-rw-r--r--app/controllers/concerns/session_tracking_concern.rb22
-rw-r--r--app/controllers/concerns/signature_verification.rb4
-rw-r--r--app/controllers/follower_accounts_controller.rb34
-rw-r--r--app/controllers/following_accounts_controller.rb34
-rw-r--r--app/controllers/invites_controller.rb10
-rw-r--r--app/controllers/media_proxy_controller.rb2
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb5
-rw-r--r--app/controllers/oauth/tokens_controller.rb14
-rw-r--r--app/controllers/settings/applications_controller.rb2
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/controllers/settings/profiles_controller.rb3
-rw-r--r--app/controllers/shares_controller.rb1
-rw-r--r--app/helpers/admin/account_moderation_notes_helper.rb10
-rw-r--r--app/helpers/jsonld_helper.rb15
-rw-r--r--app/helpers/settings_helper.rb2
-rw-r--r--app/helpers/stream_entries_helper.rb8
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js55
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js84
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/registerer.js7
-rw-r--r--app/javascript/flavours/glitch/actions/search.js2
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js16
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js118
-rw-r--r--app/javascript/flavours/glitch/components/load_gap.js33
-rw-r--r--app/javascript/flavours/glitch/components/load_more.js5
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js11
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js3
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js16
-rw-r--r--app/javascript/flavours/glitch/components/status.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js7
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js12
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js21
-rw-r--r--app/javascript/flavours/glitch/containers/card_container.js18
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.js90
-rw-r--r--app/javascript/flavours/glitch/containers/media_galleries_container.js68
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js5
-rw-r--r--app/javascript/flavours/glitch/containers/timeline_container.js12
-rw-r--r--app/javascript/flavours/glitch/containers/video_container.js26
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js3
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js47
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js57
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js13
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js24
-rw-r--r--app/javascript/flavours/glitch/features/bookmarked_statuses/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/composer/direct_warning/index.js53
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js29
-rw-r--r--app/javascript/flavours/glitch/features/composer/reply/index.js1
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/domain_blocks/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/drawer/results/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/favourited_statuses/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/getting_started_misc/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.js15
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.js10
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js30
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/standalone/community_timeline/index.js71
-rw-r--r--app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js25
-rw-r--r--app/javascript/flavours/glitch/features/standalone/public_timeline/index.js25
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js10
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js4
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js17
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js8
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js24
-rw-r--r--app/javascript/flavours/glitch/packs/public.js29
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/domain_lists.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js66
-rw-r--r--app/javascript/flavours/glitch/reducers/search.js7
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js66
-rw-r--r--app/javascript/flavours/glitch/service_worker/entry.js10
-rw-r--r--app/javascript/flavours/glitch/service_worker/web_push_notifications.js159
-rw-r--r--app/javascript/flavours/glitch/styles/about.scss76
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss13
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss119
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss34
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss6
-rw-r--r--app/javascript/flavours/glitch/styles/components/local_settings.scss7
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/components/metadata.scss32
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/footer.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss25
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light.scss218
-rw-r--r--app/javascript/flavours/glitch/styles/metadata.scss55
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss16
-rw-r--r--app/javascript/flavours/glitch/styles/stream_entries.scss38
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss127
-rw-r--r--app/javascript/flavours/glitch/util/compare_id.js10
-rw-r--r--app/javascript/flavours/glitch/util/html.js6
-rw-r--r--app/javascript/flavours/glitch/util/resize_image.js116
-rw-r--r--app/javascript/flavours/glitch/util/stream.js29
-rw-r--r--app/javascript/mastodon/actions/columns.js19
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js21
-rw-r--r--app/javascript/mastodon/actions/notifications.js24
-rw-r--r--app/javascript/mastodon/actions/push_notifications/registerer.js7
-rw-r--r--app/javascript/mastodon/actions/search.js2
-rw-r--r--app/javascript/mastodon/actions/streaming.js20
-rw-r--r--app/javascript/mastodon/actions/timelines.js49
-rw-r--r--app/javascript/mastodon/actions/trends.js32
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js46
-rw-r--r--app/javascript/mastodon/components/media_gallery.js9
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js24
-rw-r--r--app/javascript/mastodon/components/status.js6
-rw-r--r--app/javascript/mastodon/components/status_list.js1
-rw-r--r--app/javascript/mastodon/containers/card_container.js18
-rw-r--r--app/javascript/mastodon/containers/media_container.js90
-rw-r--r--app/javascript/mastodon/containers/media_galleries_container.js68
-rw-r--r--app/javascript/mastodon/containers/timeline_container.js12
-rw-r--r--app/javascript/mastodon/containers/video_container.js26
-rw-r--r--app/javascript/mastodon/features/account/components/header.js22
-rw-r--r--app/javascript/mastodon/features/community_timeline/components/section_headline.js59
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js61
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js22
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js70
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js71
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js2
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js1
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_results_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.js10
-rw-r--r--app/javascript/mastodon/features/compose/index.js16
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js61
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js4
-rw-r--r--app/javascript/mastodon/features/status/index.js44
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js17
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js11
-rw-r--r--app/javascript/mastodon/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js4
-rw-r--r--app/javascript/mastodon/features/video/index.js25
-rw-r--r--app/javascript/mastodon/locales/ar.json9
-rw-r--r--app/javascript/mastodon/locales/bg.json5
-rw-r--r--app/javascript/mastodon/locales/ca.json9
-rw-r--r--app/javascript/mastodon/locales/co.json301
-rw-r--r--app/javascript/mastodon/locales/de.json5
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json29
-rw-r--r--app/javascript/mastodon/locales/el.json247
-rw-r--r--app/javascript/mastodon/locales/en.json9
-rw-r--r--app/javascript/mastodon/locales/eo.json37
-rw-r--r--app/javascript/mastodon/locales/es.json5
-rw-r--r--app/javascript/mastodon/locales/eu.json429
-rw-r--r--app/javascript/mastodon/locales/fa.json93
-rw-r--r--app/javascript/mastodon/locales/fi.json25
-rw-r--r--app/javascript/mastodon/locales/fr.json51
-rw-r--r--app/javascript/mastodon/locales/gl.json75
-rw-r--r--app/javascript/mastodon/locales/he.json5
-rw-r--r--app/javascript/mastodon/locales/hr.json5
-rw-r--r--app/javascript/mastodon/locales/hu.json5
-rw-r--r--app/javascript/mastodon/locales/hy.json5
-rw-r--r--app/javascript/mastodon/locales/id.json5
-rw-r--r--app/javascript/mastodon/locales/io.json5
-rw-r--r--app/javascript/mastodon/locales/it.json325
-rw-r--r--app/javascript/mastodon/locales/ja.json13
-rw-r--r--app/javascript/mastodon/locales/ko.json61
-rw-r--r--app/javascript/mastodon/locales/nl.json29
-rw-r--r--app/javascript/mastodon/locales/no.json5
-rw-r--r--app/javascript/mastodon/locales/oc.json31
-rw-r--r--app/javascript/mastodon/locales/pl.json7
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json9
-rw-r--r--app/javascript/mastodon/locales/pt.json5
-rw-r--r--app/javascript/mastodon/locales/ru.json43
-rw-r--r--app/javascript/mastodon/locales/sk.json11
-rw-r--r--app/javascript/mastodon/locales/sl.json301
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json5
-rw-r--r--app/javascript/mastodon/locales/sr.json5
-rw-r--r--app/javascript/mastodon/locales/sv.json11
-rw-r--r--app/javascript/mastodon/locales/te.json5
-rw-r--r--app/javascript/mastodon/locales/th.json5
-rw-r--r--app/javascript/mastodon/locales/tr.json5
-rw-r--r--app/javascript/mastodon/locales/uk.json5
-rw-r--r--app/javascript/mastodon/locales/whitelist_co.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_sl.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json81
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json19
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json5
-rw-r--r--app/javascript/mastodon/reducers/compose.js7
-rw-r--r--app/javascript/mastodon/reducers/contexts.js94
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/search.js4
-rw-r--r--app/javascript/mastodon/reducers/settings.js15
-rw-r--r--app/javascript/mastodon/reducers/timelines.js7
-rw-r--r--app/javascript/mastodon/reducers/trends.js13
-rw-r--r--app/javascript/mastodon/service_worker/entry.js62
-rw-r--r--app/javascript/mastodon/service_worker/web_push_locales.js30
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js184
-rw-r--r--app/javascript/mastodon/storage/modifier.js7
-rw-r--r--app/javascript/mastodon/stream.js15
-rw-r--r--app/javascript/mastodon/utils/html.js6
-rw-r--r--app/javascript/mastodon/utils/resize_image.js96
-rw-r--r--app/javascript/packs/public.js28
-rw-r--r--app/javascript/skins/glitch/mastodon-light/common.scss1
-rw-r--r--app/javascript/skins/glitch/mastodon-light/names.yml5
-rw-r--r--app/javascript/styles/mastodon-light.scss229
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss240
-rw-r--r--app/javascript/styles/mastodon-light/variables.scss41
-rw-r--r--app/javascript/styles/mastodon/about.scss2
-rw-r--r--app/javascript/styles/mastodon/accounts.scss63
-rw-r--r--app/javascript/styles/mastodon/admin.scss36
-rw-r--r--app/javascript/styles/mastodon/components.scss180
-rw-r--r--app/javascript/styles/mastodon/containers.scss2
-rw-r--r--app/javascript/styles/mastodon/footer.scss10
-rw-r--r--app/javascript/styles/mastodon/forms.scss4
-rw-r--r--app/javascript/styles/mastodon/tables.scss11
-rw-r--r--app/javascript/styles/mastodon/variables.scss8
-rw-r--r--app/lib/activitypub/activity.rb9
-rw-r--r--app/lib/activitypub/activity/add.rb5
-rw-r--r--app/lib/activitypub/activity/announce.rb10
-rw-r--r--app/lib/activitypub/activity/block.rb2
-rw-r--r--app/lib/activitypub/activity/create.rb7
-rw-r--r--app/lib/activitypub/activity/follow.rb2
-rw-r--r--app/lib/activitypub/activity/remove.rb2
-rw-r--r--app/lib/activitypub/tag_manager.rb8
-rw-r--r--app/lib/formatter.rb12
-rw-r--r--app/lib/ostatus/activity/creation.rb10
-rw-r--r--app/lib/ostatus/atom_serializer.rb3
-rw-r--r--app/lib/request.rb15
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/models/account.rb31
-rw-r--r--app/models/block.rb10
-rw-r--r--app/models/concerns/account_interactions.rb13
-rw-r--r--app/models/concerns/remotable.rb3
-rw-r--r--app/models/concerns/status_threading_concern.rb23
-rw-r--r--app/models/follow.rb13
-rw-r--r--app/models/follow_request.rb16
-rw-r--r--app/models/status.rb43
-rw-r--r--app/models/tag.rb16
-rw-r--r--app/models/trending_tags.rb61
-rw-r--r--app/models/user.rb11
-rw-r--r--app/models/web/push_subscription.rb73
-rw-r--r--app/serializers/activitypub/actor_serializer.rb2
-rw-r--r--app/serializers/activitypub/block_serializer.rb2
-rw-r--r--app/serializers/activitypub/follow_serializer.rb2
-rw-r--r--app/serializers/initial_state_serializer.rb8
-rw-r--r--app/serializers/rest/account_serializer.rb3
-rw-r--r--app/serializers/rest/tag_serializer.rb11
-rw-r--r--app/serializers/rest/v2/search_serializer.rb7
-rw-r--r--app/serializers/rest/web_push_subscription_serializer.rb13
-rw-r--r--app/serializers/web/notification_serializer.rb164
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb5
-rw-r--r--app/services/activitypub/process_account_service.rb13
-rw-r--r--app/services/activitypub/process_collection_service.rb3
-rw-r--r--app/services/batched_remove_status_service.rb5
-rw-r--r--app/services/fan_out_on_write_service.rb12
-rw-r--r--app/services/fetch_link_card_service.rb4
-rw-r--r--app/services/notify_service.rb21
-rw-r--r--app/services/post_status_service.rb10
-rw-r--r--app/services/process_hashtags_service.rb6
-rw-r--r--app/services/remove_status_service.rb8
-rw-r--r--app/services/resolve_account_service.rb2
-rw-r--r--app/services/update_remote_profile_service.rb12
-rw-r--r--app/validators/email_mx_validator.rb25
-rw-r--r--app/views/about/_administration.html.haml2
-rw-r--r--app/views/about/_contact.html.haml2
-rw-r--r--app/views/about/show.html.haml2
-rw-r--r--app/views/accounts/_follow_grid.html.haml3
-rw-r--r--app/views/accounts/_follow_grid_hidden.html.haml3
-rw-r--r--app/views/accounts/_grid_card.html.haml2
-rw-r--r--app/views/accounts/_header.html.haml29
-rw-r--r--app/views/accounts/_moved_strip.html.haml4
-rw-r--r--app/views/accounts/show.html.haml1
-rw-r--r--app/views/admin/account_moderation_notes/_account_moderation_note.html.haml15
-rw-r--r--app/views/admin/accounts/show.html.haml49
-rw-r--r--app/views/admin/reports/_account.html.haml2
-rw-r--r--app/views/admin/reports/_status.html.haml26
-rw-r--r--app/views/admin/reports/show.html.haml6
-rw-r--r--app/views/admin/statuses/index.html.haml52
-rw-r--r--app/views/auth/sessions/two_factor.html.haml7
-rw-r--r--app/views/authorize_follows/_card.html.haml2
-rw-r--r--app/views/follower_accounts/index.html.haml5
-rw-r--r--app/views/following_accounts/index.html.haml5
-rw-r--r--app/views/layouts/public.html.haml6
-rw-r--r--app/views/remote_follow/new.html.haml2
-rw-r--r--app/views/remote_unfollows/_card.html.haml2
-rw-r--r--app/views/settings/exports/show.html.haml6
-rw-r--r--app/views/settings/preferences/show.html.haml3
-rw-r--r--app/views/settings/profiles/show.html.haml3
-rw-r--r--app/views/shared/_landing_strip.html.haml2
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml2
-rw-r--r--app/views/stream_entries/_simple_status.html.haml2
-rw-r--r--app/views/stream_entries/_status.html.haml2
-rw-r--r--app/views/tags/_og.html.haml2
-rw-r--r--app/views/tags/show.html.haml4
-rw-r--r--app/workers/activitypub/delivery_worker.rb15
-rw-r--r--app/workers/web/push_notification_worker.rb18
-rw-r--r--app/workers/web_push_notification_worker.rb25
310 files changed, 5686 insertions, 3014 deletions
diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb
index 34dfb458e..8d3477e66 100644
--- a/app/controllers/admin/confirmations_controller.rb
+++ b/app/controllers/admin/confirmations_controller.rb
@@ -3,6 +3,7 @@
 module Admin
   class ConfirmationsController < BaseController
     before_action :set_user
+    before_action :check_confirmation, only: [:resend]
 
     def create
       authorize @user, :confirm?
@@ -11,10 +12,28 @@ module Admin
       redirect_to admin_accounts_path
     end
 
+    def resend
+      authorize @user, :confirm?
+
+      @user.resend_confirmation_instructions
+
+      log_action :confirm, @user
+
+      flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
+      redirect_to admin_accounts_path
+    end
+
     private
 
     def set_user
       @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
     end
+
+    def check_confirmation
+      if @user.confirmed?
+        flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
+        redirect_to admin_accounts_path
+      end
+    end
   end
 end
diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb
index 522f68c98..d3c2f5e9e 100644
--- a/app/controllers/admin/reported_statuses_controller.rb
+++ b/app/controllers/admin/reported_statuses_controller.rb
@@ -3,7 +3,6 @@
 module Admin
   class ReportedStatusesController < BaseController
     before_action :set_report
-    before_action :set_status, only: [:update, :destroy]
 
     def create
       authorize :status, :update?
@@ -14,20 +13,6 @@ module Admin
       redirect_to admin_report_path(@report)
     end
 
-    def update
-      authorize @status, :update?
-      @status.update!(status_params)
-      log_action :update, @status
-      redirect_to admin_report_path(@report)
-    end
-
-    def destroy
-      authorize @status, :destroy?
-      RemovalWorker.perform_async(@status.id)
-      log_action :destroy, @status
-      render json: @status
-    end
-
     private
 
     def status_params
@@ -51,9 +36,5 @@ module Admin
     def set_report
       @report = Report.find(params[:report_id])
     end
-
-    def set_status
-      @status = @report.statuses.find(params[:id])
-    end
   end
 end
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index d5787acfb..382bfc4a2 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -5,7 +5,6 @@ module Admin
     helper_method :current_params
 
     before_action :set_account
-    before_action :set_status, only: [:update, :destroy]
 
     PER_PAGE = 20
 
@@ -26,40 +25,18 @@ module Admin
     def create
       authorize :status, :update?
 
-      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account))
+      @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)
     end
 
-    def update
-      authorize @status, :update?
-      @status.update!(status_params)
-      log_action :update, @status
-      redirect_to admin_account_statuses_path(@account.id, current_params)
-    end
-
-    def destroy
-      authorize @status, :destroy?
-      RemovalWorker.perform_async(@status.id)
-      log_action :destroy, @status
-      render json: @status
-    end
-
     private
 
-    def status_params
-      params.require(:status).permit(:sensitive)
-    end
-
     def form_status_batch_params
       params.require(:form_status_batch).permit(:action, status_ids: [])
     end
 
-    def set_status
-      @status = @account.statuses.find(params[:id])
-    end
-
     def set_account
       @account = Account.find(params[:account_id])
     end
@@ -72,5 +49,15 @@ module Admin
         page: page > 1 && page,
       }.select { |_, value| value.present? }
     end
+
+    def action_from_button
+      if params[:nsfw_on]
+        'nsfw_on'
+      elsif params[:nsfw_off]
+        'nsfw_off'
+      elsif params[:delete]
+        'delete'
+      end
+    end
   end
 end
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index a3c4008e6..259d07be8 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -21,7 +21,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   private
 
   def account_params
-    params.permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value])
+    params.permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value])
   end
 
   def user_settings_params
diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
index c4f600c54..4578cf6ca 100644
--- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
@@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
   end
 
   def load_accounts
+    return [] if @account.user_hides_network? && current_account.id != @account.id
+
     default_accounts.merge(paginated_follows).to_a
   end
 
diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb
index 90b1f7fc5..ce2bbda85 100644
--- a/app/controllers/api/v1/accounts/following_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb
@@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
   end
 
   def load_accounts
+    return [] if @account.user_hides_network? && current_account.id != @account.id
+
     default_accounts.merge(paginated_follows).to_a
   end
 
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index cbcc7ef04..c40155cb5 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -27,19 +27,17 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def account_statuses
-    default_statuses.tap do |statuses|
-      statuses.merge!(only_media_scope) if truthy_param?(:only_media)
-      statuses.merge!(pinned_scope) if truthy_param?(:pinned)
-      statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
-    end
-  end
-
-  def default_statuses
-    permitted_account_statuses.paginate_by_max_id(
+    statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
+    statuses = statuses.paginate_by_max_id(
       limit_param(DEFAULT_STATUSES_LIMIT),
       params[:max_id],
       params[:since_id]
     )
+
+    statuses.merge!(only_media_scope) if truthy_param?(:only_media)
+    statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
+
+    statuses
   end
 
   def permitted_account_statuses
diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb
new file mode 100644
index 000000000..1a19bd0ef
--- /dev/null
+++ b/app/controllers/api/v1/push/subscriptions_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class Api::V1::Push::SubscriptionsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :push }
+  before_action :require_user!
+  before_action :set_web_push_subscription
+
+  def create
+    @web_subscription&.destroy!
+
+    @web_subscription = ::Web::PushSubscription.create!(
+      endpoint: subscription_params[:endpoint],
+      key_p256dh: subscription_params[:keys][:p256dh],
+      key_auth: subscription_params[:keys][:auth],
+      data: data_params,
+      user_id: current_user.id,
+      access_token_id: doorkeeper_token.id
+    )
+
+    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
+  end
+
+  def show
+    raise ActiveRecord::RecordNotFound if @web_subscription.nil?
+
+    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
+  end
+
+  def update
+    raise ActiveRecord::RecordNotFound if @web_subscription.nil?
+
+    @web_subscription.update!(data: data_params)
+
+    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
+  end
+
+  def destroy
+    @web_subscription&.destroy!
+    render_empty
+  end
+
+  private
+
+  def set_web_push_subscription
+    @web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
+  end
+
+  def subscription_params
+    params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
+  end
+
+  def data_params
+    return {} if params[:data].blank?
+    params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention])
+  end
+end
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
index bba6a6f48..54f8be667 100644
--- a/app/controllers/api/v1/statuses/pins_controller.rb
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -39,7 +39,7 @@ class Api::V1::Statuses::PinsController < Api::BaseController
       adapter: ActivityPub::Adapter
     ).as_json
 
-    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account)
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id)
   end
 
   def distribute_remove_activity!
@@ -49,6 +49,6 @@ class Api::V1::Statuses::PinsController < Api::BaseController
       adapter: ActivityPub::Adapter
     ).as_json
 
-    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account)
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id)
   end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 01880565c..289d91045 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -10,6 +10,12 @@ class Api::V1::StatusesController < Api::BaseController
 
   respond_to :json
 
+  # This API was originally unlimited, pagination cannot be introduced without
+  # breaking backwards-compatibility. Arbitrarily high number to cover most
+  # conversations as quasi-unlimited, it would be too much work to render more
+  # than this anyway
+  CONTEXT_LIMIT = 4_096
+
   def show
     cached  = Rails.cache.read(@status.cache_key)
     @status = cached unless cached.nil?
@@ -17,8 +23,8 @@ class Api::V1::StatusesController < Api::BaseController
   end
 
   def context
-    ancestors_results   = @status.in_reply_to_id.nil? ? [] : @status.ancestors(DEFAULT_STATUSES_LIMIT, current_account)
-    descendants_results = @status.descendants(DEFAULT_STATUSES_LIMIT, current_account)
+    ancestors_results   = @status.in_reply_to_id.nil? ? [] : @status.ancestors(CONTEXT_LIMIT, current_account)
+    descendants_results = @status.descendants(CONTEXT_LIMIT, current_account)
     loaded_ancestors    = cache_collection(ancestors_results, Status)
     loaded_descendants  = cache_collection(descendants_results, Status)
 
diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb
index d455227eb..ef64078be 100644
--- a/app/controllers/api/v1/timelines/direct_controller.rb
+++ b/app/controllers/api/v1/timelines/direct_controller.rb
@@ -23,15 +23,18 @@ class Api::V1::Timelines::DirectController < Api::BaseController
   end
 
   def direct_statuses
-    direct_timeline_statuses.paginate_by_max_id(
-      limit_param(DEFAULT_STATUSES_LIMIT),
-      params[:max_id],
-      params[:since_id]
-    )
+    direct_timeline_statuses
   end
 
   def direct_timeline_statuses
-    Status.as_direct_timeline(current_account)
+    # this query requires built in pagination.
+    Status.as_direct_timeline(
+      current_account,
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id],
+      true # returns array of cache_ids object
+    )
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb
new file mode 100644
index 000000000..bcea9857e
--- /dev/null
+++ b/app/controllers/api/v1/trends_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::V1::TrendsController < Api::BaseController
+  before_action :set_tags
+
+  respond_to :json
+
+  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/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
new file mode 100644
index 000000000..2e91d68ee
--- /dev/null
+++ b/app/controllers/api/v2/search_controller.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class Api::V2::SearchController < Api::V1::SearchController
+  def index
+    @search = Search.new(search)
+    render json: @search, serializer: REST::V2::SearchSerializer
+  end
+end
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index 249e7c186..fe8e42580 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -31,22 +31,23 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
       endpoint: subscription_params[:endpoint],
       key_p256dh: subscription_params[:keys][:p256dh],
       key_auth: subscription_params[:keys][:auth],
-      data: data
+      data: data,
+      user_id: active_session.user_id,
+      access_token_id: active_session.access_token_id
     )
 
     active_session.update!(web_push_subscription: web_subscription)
 
-    render json: web_subscription.as_payload
+    render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
   end
 
   def update
     params.require([:id])
 
     web_subscription = ::Web::PushSubscription.find(params[:id])
-
     web_subscription.update!(data: data_params)
 
-    render json: web_subscription.as_payload
+    render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
   end
 
   private
@@ -56,6 +57,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
   end
 
   def data_params
-    @data_params ||= params.require(:data).permit(:alerts)
+    @data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention])
   end
 end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 158c0c10e..cc92894a5 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base
 
   include Localized
   include UserTrackingConcern
+  include SessionTrackingConcern
 
   helper_method :current_account
   helper_method :current_session
@@ -20,6 +21,7 @@ class ApplicationController < ActionController::Base
   rescue_from ActionController::RoutingError, with: :not_found
   rescue_from ActiveRecord::RecordNotFound, with: :not_found
   rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
+  rescue_from ActionController::UnknownFormat, with: :not_acceptable
   rescue_from Mastodon::NotPermittedError, with: :forbidden
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
@@ -142,6 +144,10 @@ class ApplicationController < ActionController::Base
     respond_with_error(422)
   end
 
+  def not_acceptable
+    respond_with_error(406)
+  end
+
   def single_user_mode?
     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists?
   end
diff --git a/app/controllers/concerns/session_tracking_concern.rb b/app/controllers/concerns/session_tracking_concern.rb
new file mode 100644
index 000000000..45361b019
--- /dev/null
+++ b/app/controllers/concerns/session_tracking_concern.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module SessionTrackingConcern
+  extend ActiveSupport::Concern
+
+  UPDATE_SIGN_IN_HOURS = 24
+
+  included do
+    before_action :set_session_activity
+  end
+
+  private
+
+  def set_session_activity
+    return unless session_needs_update?
+    current_session.touch
+  end
+
+  def session_needs_update?
+    !current_session.nil? && current_session.updated_at < UPDATE_SIGN_IN_HOURS.hours.ago
+  end
+end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index f289228d3..41aa1c8a6 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -107,9 +107,7 @@ module SignatureVerification
 
   def incompatible_signature?(signature_params)
     signature_params['keyId'].blank? ||
-      signature_params['signature'].blank? ||
-      signature_params['algorithm'].blank? ||
-      signature_params['algorithm'] != 'rsa-sha256'
+      signature_params['signature'].blank?
   end
 
   def account_from_key_id(key_id)
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index c74d3f86d..f5670c6bf 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -4,16 +4,19 @@ class FollowerAccountsController < ApplicationController
   include AccountControllerConcern
 
   def index
-    @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
-
     respond_to do |format|
       format.html do
         use_pack 'public'
 
-        @relationships = AccountRelationshipsPresenter.new(@follows.map(&:account_id), current_user.account_id) if user_signed_in?
+        next if @account.user_hides_network?
+
+        follows
+        @relationships = AccountRelationshipsPresenter.new(follows.map(&:account_id), current_user.account_id) if user_signed_in?
       end
 
       format.json do
+        raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
+
         render json: collection_presenter,
                serializer: ActivityPub::CollectionSerializer,
                adapter: ActivityPub::Adapter,
@@ -24,28 +27,31 @@ class FollowerAccountsController < ApplicationController
 
   private
 
+  def follows
+    @follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
+  end
+
   def page_url(page)
     account_followers_url(@account, page: page) unless page.nil?
   end
 
   def collection_presenter
-    page = ActivityPub::CollectionPresenter.new(
-      id: account_followers_url(@account, page: params.fetch(:page, 1)),
-      type: :ordered,
-      size: @account.followers_count,
-      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
-      part_of: account_followers_url(@account),
-      next: page_url(@follows.next_page),
-      prev: page_url(@follows.prev_page)
-    )
     if params[:page].present?
-      page
+      ActivityPub::CollectionPresenter.new(
+        id: account_followers_url(@account, page: params.fetch(:page, 1)),
+        type: :ordered,
+        size: @account.followers_count,
+        items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
+        part_of: account_followers_url(@account),
+        next: page_url(follows.next_page),
+        prev: page_url(follows.prev_page)
+      )
     else
       ActivityPub::CollectionPresenter.new(
         id: account_followers_url(@account),
         type: :ordered,
         size: @account.followers_count,
-        first: page
+        first: page_url(1)
       )
     end
   end
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 4c1e3f327..098b2a20c 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -4,16 +4,19 @@ class FollowingAccountsController < ApplicationController
   include AccountControllerConcern
 
   def index
-    @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
-
     respond_to do |format|
       format.html do
         use_pack 'public'
 
-        @relationships = AccountRelationshipsPresenter.new(@follows.map(&:target_account_id), current_user.account_id) if user_signed_in?
+        next if @account.user_hides_network?
+
+        follows
+        @relationships = AccountRelationshipsPresenter.new(follows.map(&:target_account_id), current_user.account_id) if user_signed_in?
       end
 
       format.json do
+        raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
+
         render json: collection_presenter,
                serializer: ActivityPub::CollectionSerializer,
                adapter: ActivityPub::Adapter,
@@ -24,28 +27,31 @@ class FollowingAccountsController < ApplicationController
 
   private
 
+  def follows
+    @follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
+  end
+
   def page_url(page)
     account_following_index_url(@account, page: page) unless page.nil?
   end
 
   def collection_presenter
-    page = ActivityPub::CollectionPresenter.new(
-      id: account_following_index_url(@account, page: params.fetch(:page, 1)),
-      type: :ordered,
-      size: @account.following_count,
-      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
-      part_of: account_following_index_url(@account),
-      next: page_url(@follows.next_page),
-      prev: page_url(@follows.prev_page)
-    )
     if params[:page].present?
-      page
+      ActivityPub::CollectionPresenter.new(
+        id: account_following_index_url(@account, page: params.fetch(:page, 1)),
+        type: :ordered,
+        size: @account.following_count,
+        items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
+        part_of: account_following_index_url(@account),
+        next: page_url(follows.next_page),
+        prev: page_url(follows.prev_page)
+      )
     else
       ActivityPub::CollectionPresenter.new(
         id: account_following_index_url(@account),
         type: :ordered,
         size: @account.following_count,
-        first: page
+        first: page_url(1)
       )
     end
   end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 189e4072e..70818610c 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -11,7 +11,7 @@ class InvitesController < ApplicationController
   def index
     authorize :invite, :create?
 
-    @invites = Invite.where(user: current_user)
+    @invites = invites
     @invite  = Invite.new(expires_in: 1.day.to_i)
   end
 
@@ -24,13 +24,13 @@ class InvitesController < ApplicationController
     if @invite.save
       redirect_to invites_path
     else
-      @invites = Invite.where(user: current_user)
+      @invites = invites
       render :index
     end
   end
 
   def destroy
-    @invite = Invite.where(user: current_user).find(params[:id])
+    @invite = invites.find(params[:id])
     authorize @invite, :destroy?
     @invite.expire!
     redirect_to invites_path
@@ -42,6 +42,10 @@ class InvitesController < ApplicationController
     use_pack 'settings'
   end
 
+  def invites
+    Invite.where(user: current_user)
+  end
+
   def resource_params
     params.require(:invite).permit(:max_uses, :expires_in)
   end
diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb
index 155670837..d820b257e 100644
--- a/app/controllers/media_proxy_controller.rb
+++ b/app/controllers/media_proxy_controller.rb
@@ -8,6 +8,8 @@ class MediaProxyController < ApplicationController
       if lock.acquired?
         @media_attachment = MediaAttachment.remote.find(params[:id])
         redownload! if @media_attachment.needs_redownload? && !reject_media?
+      else
+        raise Mastodon::RaceConditionError
       end
     end
 
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index f95d672ec..1e420b3e7 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -9,6 +9,11 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
 
   include Localized
 
+  def destroy
+    Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner)
+    super
+  end
+
   private
 
   def store_current_location
diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb
new file mode 100644
index 000000000..fa6d58f25
--- /dev/null
+++ b/app/controllers/oauth/tokens_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Oauth::TokensController < Doorkeeper::TokensController
+  def revoke
+    unsubscribe_for_token if authorized? && token.accessible?
+    super
+  end
+
+  private
+
+  def unsubscribe_for_token
+    Web::PushSubscription.where(access_token_id: token.id).delete_all
+  end
+end
diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb
index 35a6f7f9e..03c890a50 100644
--- a/app/controllers/settings/applications_controller.rb
+++ b/app/controllers/settings/applications_controller.rb
@@ -6,7 +6,7 @@ class Settings::ApplicationsController < Settings::BaseController
   before_action :prepare_scopes, only: [:create, :update]
 
   def index
-    @applications = current_user.applications.page(params[:page])
+    @applications = current_user.applications.order(id: :desc).page(params[:page])
   end
 
   def new
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index c853b5ab7..425664d49 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -40,6 +40,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_reduce_motion,
       :setting_system_font_ui,
       :setting_noindex,
+      :setting_hide_network,
       notification_emails: %i(follow follow_request reblog favourite mention digest),
       interactions: %i(must_be_follower must_be_following)
     )
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 2b8330f2e..918dbc6c6 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -17,6 +17,7 @@ class Settings::ProfilesController < Settings::BaseController
       ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
       redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
     else
+      @account.build_fields
       render :show
     end
   end
@@ -24,7 +25,7 @@ class Settings::ProfilesController < Settings::BaseController
   private
 
   def account_params
-    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value])
+    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value])
   end
 
   def set_account
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
index 3cbaccb35..4624c29a6 100644
--- a/app/controllers/shares_controller.rb
+++ b/app/controllers/shares_controller.rb
@@ -16,6 +16,7 @@ class SharesController < ApplicationController
 
   def initial_state_params
     text = [params[:title], params[:text], params[:url]].compact.join(' ')
+
     {
       settings: Web::Setting.find_by(user: current_user)&.data || {},
       push_subscription: current_account.user.web_push_subscription(current_session),
diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb
index fdfadef08..49e764cef 100644
--- a/app/helpers/admin/account_moderation_notes_helper.rb
+++ b/app/helpers/admin/account_moderation_notes_helper.rb
@@ -10,10 +10,16 @@ module Admin::AccountModerationNotesHelper
     end
   end
 
+  def admin_account_inline_link_to(account)
+    link_to admin_account_path(account.id), class: name_tag_classes(account, true) do
+      content_tag(:span, account.acct, class: 'username')
+    end
+  end
+
   private
 
-  def name_tag_classes(account)
-    classes = ['name-tag']
+  def name_tag_classes(account, inline = false)
+    classes = [inline ? 'inline-name-tag' : 'name-tag']
     classes << 'suspended' if account.suspended?
     classes.join(' ')
   end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index e9056166c..9d2b6cf00 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -52,18 +52,22 @@ module JsonLdHelper
     graph.dump(:normalize)
   end
 
-  def fetch_resource(uri, id)
+  def fetch_resource(uri, id, on_behalf_of = nil)
     unless id
-      json = fetch_resource_without_id_validation(uri)
+      json = fetch_resource_without_id_validation(uri, on_behalf_of)
       return unless json
       uri = json['id']
     end
 
-    json = fetch_resource_without_id_validation(uri)
+    json = fetch_resource_without_id_validation(uri, on_behalf_of)
     json.present? && json['id'] == uri ? json : nil
   end
 
-  def fetch_resource_without_id_validation(uri)
+  def fetch_resource_without_id_validation(uri, on_behalf_of = nil)
+    build_request(uri, on_behalf_of).perform do |response|
+      return body_to_json(response.body_with_limit) if response.code == 200
+    end
+    # If request failed, retry without doing it on behalf of a user
     build_request(uri).perform do |response|
       response.code == 200 ? body_to_json(response.body_with_limit) : nil
     end
@@ -85,8 +89,9 @@ module JsonLdHelper
 
   private
 
-  def build_request(uri)
+  def build_request(uri, on_behalf_of = nil)
     request = Request.new(:get, uri)
+    request.on_behalf_of(on_behalf_of) if on_behalf_of
     request.add_headers('Accept' => 'application/activity+json, application/ld+json')
     request
   end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index f78e5fbc3..ba728eb32 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -6,6 +6,7 @@ module SettingsHelper
     ar: 'العربية',
     bg: 'Български',
     ca: 'Català',
+    co: 'Corsu',
     de: 'Deutsch',
     el: 'Ελληνικά',
     eo: 'Esperanto',
@@ -32,6 +33,7 @@ module SettingsHelper
     'pt-BR': 'Português do Brasil',
     ru: 'Русский',
     sk: 'Slovensky',
+    sl: 'Slovenščina',
     sr: 'Српски',
     'sr-Latn': 'Srpski (latinica)',
     sv: 'Svenska',
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index c6f12ecd4..a91a28935 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -4,8 +4,12 @@ module StreamEntriesHelper
   EMBEDDED_CONTROLLER = 'statuses'
   EMBEDDED_ACTION = 'embed'
 
-  def display_name(account)
-    account.display_name.presence || account.username
+  def display_name(account, **options)
+    if options[:custom_emojify]
+      Formatter.instance.format_display_name(account, options)
+    else
+      account.display_name.presence || account.username
+    end
   end
 
   def account_description(account)
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 8a6ca3699..b7f706a83 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -3,14 +3,9 @@ import { CancelToken } from 'axios';
 import { throttle } from 'lodash';
 import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
 import { useEmoji } from './emojis';
+import resizeImage from 'flavours/glitch/util/resize_image';
 
-import {
-  updateTimeline,
-  refreshHomeTimeline,
-  refreshCommunityTimeline,
-  refreshPublicTimeline,
-  refreshDirectTimeline,
-} from './timelines';
+import { updateTimeline } from './timelines';
 
 let cancelFetchComposeSuggestionsAccounts;
 
@@ -21,6 +16,7 @@ export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
 export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
 export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
 export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_DIRECT          = 'COMPOSE_DIRECT';
 export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
 export const COMPOSE_RESET           = 'COMPOSE_RESET';
 export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
@@ -102,6 +98,19 @@ export function mentionCompose(account, router) {
   };
 };
 
+export function directCompose(account, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_DIRECT,
+      account: account,
+    });
+
+    if (!getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
 export function submitCompose() {
   return function (dispatch, getState) {
     let status = getState().getIn(['compose', 'text'], '');
@@ -136,21 +145,19 @@ export function submitCompose() {
 
       // To make the app more responsive, immediately get the status into the columns
 
-      const insertOrRefresh = (timelineId, refreshAction) => {
-        if (getState().getIn(['timelines', timelineId, 'online'])) {
+      const insertIfOnline = (timelineId) => {
+        if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) {
           dispatch(updateTimeline(timelineId, { ...response.data }));
-        } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
-          dispatch(refreshAction());
         }
       };
 
-      insertOrRefresh('home', refreshHomeTimeline);
+      insertIfOnline('home');
 
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
-        insertOrRefresh('community', refreshCommunityTimeline);
-        insertOrRefresh('public', refreshPublicTimeline);
+        insertIfOnline('community');
+        insertIfOnline('public');
       } else if (response.data.visibility === 'direct') {
-        insertOrRefresh('direct', refreshDirectTimeline);
+        insertIfOnline('direct');
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
@@ -193,18 +200,14 @@ export function uploadCompose(files) {
 
     dispatch(uploadComposeRequest());
 
-    let data = new FormData();
-    data.append('file', files[0]);
+    resizeImage(files[0]).then(file => {
+      const data = new FormData();
+      data.append('file', file);
 
-    api(getState).post('/api/v1/media', data, {
-      onUploadProgress: function (e) {
-        dispatch(uploadComposeProgress(e.loaded, e.total));
-      },
-    }).then(function (response) {
-      dispatch(uploadComposeSuccess(response.data));
-    }).catch(function (error) {
-      dispatch(uploadComposeFail(error));
-    });
+      return api(getState).post('/api/v1/media', data, {
+        onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)),
+      }).then(({ data }) => dispatch(uploadComposeSuccess(data)));
+    }).catch(error => dispatch(uploadComposeFail(error)));
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index cf27eff90..68c46a732 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -1,8 +1,8 @@
 import api, { getLinks } from 'flavours/glitch/util/api';
-import { List as ImmutableList } from 'immutable';
 import IntlMessageFormat from 'intl-messageformat';
 import { fetchRelationships } from './accounts';
 import { defineMessages } from 'react-intl';
+import { unescapeHTML } from 'flavours/glitch/util/html';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 
@@ -17,10 +17,6 @@ export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR
 // Mark one for delete
 export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
 
-export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
-export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
-export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL';
-
 export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
 export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
 export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
@@ -40,13 +36,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
   }
 };
 
-const unescapeHTML = (html) => {
-  const wrapper = document.createElement('div');
-  html = html.replace(/<br \/>|<br>|\n/g, ' ');
-  wrapper.innerHTML = html;
-  return wrapper.textContent;
-};
-
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
     const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
@@ -78,84 +67,37 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
 
 const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
 
-export function refreshNotifications() {
-  return (dispatch, getState) => {
-    const params = {};
-    const ids    = getState().getIn(['notifications', 'items']);
-
-    let skipLoading = false;
-
-    if (ids.size > 0) {
-      params.since_id = ids.first().get('id');
-    }
-
-    if (getState().getIn(['notifications', 'loaded'])) {
-      skipLoading = true;
-    }
-
-    params.exclude_types = excludeTypesFromSettings(getState());
-
-    dispatch(refreshNotificationsRequest(skipLoading));
-
-    api(getState).get('/api/v1/notifications', { params }).then(response => {
-      const next = getLinks(response).refs.find(link => link.rel === 'next');
-
-      dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
-      fetchRelatedRelationships(dispatch, response.data);
-    }).catch(error => {
-      dispatch(refreshNotificationsFail(error, skipLoading));
-    });
-  };
-};
-
-export function refreshNotificationsRequest(skipLoading) {
-  return {
-    type: NOTIFICATIONS_REFRESH_REQUEST,
-    skipLoading,
-  };
-};
 
-export function refreshNotificationsSuccess(notifications, skipLoading, next) {
-  return {
-    type: NOTIFICATIONS_REFRESH_SUCCESS,
-    notifications,
-    accounts: notifications.map(item => item.account),
-    statuses: notifications.map(item => item.status).filter(status => !!status),
-    skipLoading,
-    next,
-  };
-};
+const noOp = () => {};
 
-export function refreshNotificationsFail(error, skipLoading) {
-  return {
-    type: NOTIFICATIONS_REFRESH_FAIL,
-    error,
-    skipLoading,
-  };
-};
-
-export function expandNotifications() {
+export function expandNotifications({ maxId } = {}, done = noOp) {
   return (dispatch, getState) => {
-    const items  = getState().getIn(['notifications', 'items'], ImmutableList());
+    const notifications = getState().get('notifications');
 
-    if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+    if (notifications.get('isLoading')) {
+      done();
       return;
     }
 
     const params = {
-      max_id: items.last().get('id'),
-      limit: 20,
+      max_id: maxId,
       exclude_types: excludeTypesFromSettings(getState()),
     };
 
+    if (!maxId && notifications.get('items').size > 0) {
+      params.since_id = notifications.getIn(['items', 0]);
+    }
+
     dispatch(expandNotificationsRequest());
 
     api(getState).get('/api/v1/notifications', { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
       fetchRelatedRelationships(dispatch, response.data);
+      done();
     }).catch(error => {
       dispatch(expandNotificationsFail(error));
+      done();
     });
   };
 };
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
index 5ad11f73f..91f442415 100644
--- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
+++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
@@ -56,13 +56,6 @@ export function register () {
     dispatch(setBrowserSupport(supportsPushNotifications));
     const me = getState().getIn(['meta', 'me']);
 
-    if (me && !pushNotificationsSetting.get(me)) {
-      const alerts = getState().getIn(['push_notifications', 'alerts']);
-      if (alerts) {
-        pushNotificationsSetting.set(me, { alerts: alerts });
-      }
-    }
-
     if (supportsPushNotifications) {
       if (!getApplicationServerKey()) {
         console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js
index e86bd848e..13885c600 100644
--- a/app/javascript/flavours/glitch/actions/search.js
+++ b/app/javascript/flavours/glitch/actions/search.js
@@ -1,4 +1,5 @@
 import api from 'flavours/glitch/util/api';
+import { fetchRelationships } from './accounts';
 
 export const SEARCH_CHANGE = 'SEARCH_CHANGE';
 export const SEARCH_CLEAR  = 'SEARCH_CLEAR';
@@ -38,6 +39,7 @@ export function submitSearch() {
       },
     }).then(response => {
       dispatch(fetchSearchSuccess(response.data));
+      dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
       dispatch(fetchSearchFail(error));
     });
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index ae51e8349..6e34d0be6 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -2,11 +2,10 @@ import { connectStream } from 'flavours/glitch/util/stream';
 import {
   updateTimeline,
   deleteFromTimelines,
-  refreshHomeTimeline,
-  connectTimeline,
+  expandHomeTimeline,
   disconnectTimeline,
 } from './timelines';
-import { updateNotifications, refreshNotifications } from './notifications';
+import { updateNotifications, expandNotifications } from './notifications';
 import { getLocale } from 'mastodon/locales';
 
 const { messages } = getLocale();
@@ -16,10 +15,6 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
   return connectStream (path, pollingRefresh, (dispatch, getState) => {
     const locale = getState().getIn(['meta', 'locale']);
     return {
-      onConnect() {
-        dispatch(connectTimeline(timelineId));
-      },
-
       onDisconnect() {
         dispatch(disconnectTimeline(timelineId));
       },
@@ -41,10 +36,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
   });
 }
 
-function refreshHomeTimelineAndNotification (dispatch) {
-  dispatch(refreshHomeTimeline());
-  dispatch(refreshNotifications());
-}
+const refreshHomeTimelineAndNotification = (dispatch, done) => {
+  dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+};
 
 export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
 export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index d99c6d98b..9597fe89d 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -4,32 +4,16 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
 export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
 
-export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
-export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
-export const TIMELINE_REFRESH_FAIL    = 'TIMELINE_REFRESH_FAIL';
-
 export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
 export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
 export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
 export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
 
-export function refreshTimelineSuccess(timeline, statuses, skipLoading, next, partial) {
-  return {
-    type: TIMELINE_REFRESH_SUCCESS,
-    timeline,
-    statuses,
-    skipLoading,
-    next,
-    partial,
-  };
-};
-
 export function updateTimeline(timeline, status) {
   return (dispatch, getState) => {
     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
@@ -77,97 +61,43 @@ export function deleteFromTimelines(id) {
   };
 };
 
-export function refreshTimelineRequest(timeline, skipLoading) {
-  return {
-    type: TIMELINE_REFRESH_REQUEST,
-    timeline,
-    skipLoading,
-  };
-};
-
-export function refreshTimeline(timelineId, path, params = {}) {
-  return function (dispatch, getState) {
-    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
-
-    if (timeline.get('isLoading') || (timeline.get('online') && !timeline.get('isPartial'))) {
-      return;
-    }
-
-    const ids      = timeline.get('items', ImmutableList());
-    const newestId = ids.size > 0 ? ids.first() : null;
-
-    let skipLoading = timeline.get('loaded');
-
-    if (newestId !== null) {
-      params.since_id = newestId;
-    }
+const noOp = () => {};
 
-    dispatch(refreshTimelineRequest(timelineId, skipLoading));
-
-    api(getState).get(path, { params }).then(response => {
-      if (response.status === 206) {
-        dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true));
-      } else {
-        const next = getLinks(response).refs.find(link => link.rel === 'next');
-        dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false));
-      }
-    }).catch(error => {
-      dispatch(refreshTimelineFail(timelineId, error, skipLoading));
-    });
-  };
-};
-
-export const refreshHomeTimeline            = () => refreshTimeline('home', '/api/v1/timelines/home');
-export const refreshPublicTimeline          = () => refreshTimeline('public', '/api/v1/timelines/public');
-export const refreshCommunityTimeline       = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
-export const refreshDirectTimeline          = () => refreshTimeline('direct', '/api/v1/timelines/direct');
-export const refreshAccountTimeline         = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies });
-export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
-export const refreshAccountMediaTimeline    = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
-export const refreshHashtagTimeline         = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
-export const refreshListTimeline            = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
-
-export function refreshTimelineFail(timeline, error, skipLoading) {
-  return {
-    type: TIMELINE_REFRESH_FAIL,
-    timeline,
-    error,
-    skipLoading,
-    skipAlert: error.response && error.response.status === 404,
-  };
-};
-
-export function expandTimeline(timelineId, path, params = {}) {
+export function expandTimeline(timelineId, path, params = {}, done = noOp) {
   return (dispatch, getState) => {
     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
-    const ids      = timeline.get('items', ImmutableList());
 
-    if (timeline.get('isLoading') || ids.size === 0) {
+    if (timeline.get('isLoading')) {
+      done();
       return;
     }
 
-    params.max_id = ids.last();
-    params.limit  = 10;
+    if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
+      params.since_id = timeline.getIn(['items', 0]);
+    }
 
     dispatch(expandTimelineRequest(timelineId));
 
     api(getState).get(path, { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
-      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206));
+      done();
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error));
+      done();
     });
   };
 };
 
-export const expandHomeTimeline         = () => expandTimeline('home', '/api/v1/timelines/home');
-export const expandPublicTimeline       = () => expandTimeline('public', '/api/v1/timelines/public');
-export const expandCommunityTimeline    = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
-export const expandDirectTimeline       = () => expandTimeline('direct', '/api/v1/timelines/direct');
-export const expandAccountTimeline      = (accountId, withReplies) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies })
-export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
-export const expandHashtagTimeline      = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
-export const expandListTimeline         = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
+export const expandHomeTimeline         = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
+export const expandPublicTimeline       = ({ maxId } = {}, done = noOp) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }, done);
+export const expandCommunityTimeline    = ({ maxId } = {}, done = noOp) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }, done);
+export const expandDirectTimeline       = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
+export const expandAccountTimeline      = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
+export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
+export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
+export const expandHashtagTimeline      = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done);
+export const expandListTimeline         = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 
 export function expandTimelineRequest(timeline) {
   return {
@@ -176,12 +106,13 @@ export function expandTimelineRequest(timeline) {
   };
 };
 
-export function expandTimelineSuccess(timeline, statuses, next) {
+export function expandTimelineSuccess(timeline, statuses, next, partial) {
   return {
     type: TIMELINE_EXPAND_SUCCESS,
     timeline,
     statuses,
     next,
+    partial,
   };
 };
 
@@ -201,13 +132,6 @@ export function scrollTopTimeline(timeline, top) {
   };
 };
 
-export function connectTimeline(timeline) {
-  return {
-    type: TIMELINE_CONNECT,
-    timeline,
-  };
-};
-
 export function disconnectTimeline(timeline) {
   return {
     type: TIMELINE_DISCONNECT,
diff --git a/app/javascript/flavours/glitch/components/load_gap.js b/app/javascript/flavours/glitch/components/load_gap.js
new file mode 100644
index 000000000..012303ae1
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/load_gap.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
+});
+
+@injectIntl
+export default class LoadGap extends React.PureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    maxId: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onClick(this.props.maxId);
+  }
+
+  render () {
+    const { disabled, intl } = this.props;
+
+    return (
+      <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
+        <i className='fa fa-ellipsis-h' />
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/load_more.js b/app/javascript/flavours/glitch/components/load_more.js
index c4c8c94a2..389c3e1e1 100644
--- a/app/javascript/flavours/glitch/components/load_more.js
+++ b/app/javascript/flavours/glitch/components/load_more.js
@@ -6,6 +6,7 @@ export default class LoadMore extends React.PureComponent {
 
   static propTypes = {
     onClick: PropTypes.func,
+    disabled: PropTypes.bool,
     visible: PropTypes.bool,
   }
 
@@ -14,10 +15,10 @@ export default class LoadMore extends React.PureComponent {
   }
 
   render() {
-    const { visible } = this.props;
+    const { disabled, visible } = this.props;
 
     return (
-      <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
+      <button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
         <FormattedMessage id='status.load_more' defaultMessage='Load more' />
       </button>
     );
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 7f5150f7b..d90f9bdb4 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -40,6 +40,7 @@ class Item extends React.PureComponent {
     size: PropTypes.number.isRequired,
     letterbox: PropTypes.bool,
     onClick: PropTypes.func.isRequired,
+    displayWidth: PropTypes.number,
   };
 
   static defaultProps = {
@@ -78,7 +79,7 @@ class Item extends React.PureComponent {
   }
 
   render () {
-    const { attachment, index, size, standalone, letterbox } = this.props;
+    const { attachment, index, size, standalone, letterbox, displayWidth } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -141,7 +142,7 @@ class Item extends React.PureComponent {
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
       const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
-      const sizes  = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+      const sizes  = hasSize ? `${displayWidth * (width / 100)}px` : null;
 
       const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
       const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
@@ -235,7 +236,7 @@ export default class MediaGallery extends React.PureComponent {
   }
 
   handleRef = (node) => {
-    if (node && this.isStandaloneEligible()) {
+    if (node /*&& this.isStandaloneEligible()*/) {
       // offsetWidth triggers a layout, so only calculate when we need to
       this.setState({
         width: node.offsetWidth,
@@ -272,9 +273,9 @@ export default class MediaGallery extends React.PureComponent {
       );
     } else {
       if (this.isStandaloneEligible()) {
-        children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} />;
+        children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />;
       } else {
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} />);
+        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} />);
       }
     }
 
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index 789e117c7..89f81f58e 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -6,6 +6,7 @@ export default class ModalRoot extends React.PureComponent {
   static propTypes = {
     children: PropTypes.node,
     onClose: PropTypes.func.isRequired,
+    noEsc: PropTypes.bool,
   };
 
   state = {
@@ -16,7 +17,7 @@ export default class ModalRoot extends React.PureComponent {
 
   handleKeyUp = (e) => {
     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
-         && !!this.props.children && !this.props.props.noEsc) {
+         && !!this.props.children && !this.props.noEsc) {
       this.props.onClose();
     }
   }
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index df3ace4c1..b96b4dd98 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent {
 
   static propTypes = {
     scrollKey: PropTypes.string.isRequired,
-    onScrollToBottom: PropTypes.func,
+    onLoadMore: PropTypes.func,
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
@@ -44,9 +44,11 @@ export default class ScrollableList extends PureComponent {
       const { scrollTop, scrollHeight, clientHeight } = this.node;
       const offset = scrollHeight - scrollTop - clientHeight;
 
-      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
-        this.props.onScrollToBottom();
-      } else if (scrollTop < 100 && this.props.onScrollToTop) {
+      if (400 > offset && this.props.onLoadMore && !this.props.isLoading) {
+        this.props.onLoadMore();
+      }
+
+      if (scrollTop < 100 && this.props.onScrollToTop) {
         this.props.onScrollToTop();
       } else if (this.props.onScroll) {
         this.props.onScroll();
@@ -144,15 +146,15 @@ export default class ScrollableList extends PureComponent {
 
   handleLoadMore = (e) => {
     e.preventDefault();
-    this.props.onScrollToBottom();
+    this.props.onLoadMore();
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
-    const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
+    const loadMore     = (hasMore && childrenCount > 0 && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
     let scrollableArea = null;
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index bff396f04..c93705266 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -32,6 +32,8 @@ export default class Status extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
+    onMention: PropTypes.func,
     onPin: PropTypes.func,
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
@@ -257,8 +259,8 @@ export default class Status extends ImmutablePureComponent {
     }
   };
 
-  handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  handleOpenVideo = (media, startTime) => {
+    this.props.onOpenVideo(media, startTime);
   }
 
   handleHotkeyReply = e => {
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index da6e4e6ba..6ae4bc08d 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -10,6 +10,7 @@ import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   block: { id: 'account.block', defaultMessage: 'Block @{name}' },
@@ -44,6 +45,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onMute: PropTypes.func,
     onBlock: PropTypes.func,
@@ -98,6 +100,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
 
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  }
+
   handleMuteClick = () => {
     this.props.onMute(this.props.status.get('account'));
   }
@@ -157,6 +163,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 32b0770cb..6542df65b 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -98,7 +98,7 @@ export default class StatusContent extends React.PureComponent {
     const [ startX, startY ] = this.startXY;
     const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
 
-    if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
+    if (e.target.localName === 'button' || e.target.localName == 'video' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
       return;
     }
 
@@ -188,11 +188,9 @@ export default class StatusContent extends React.PureComponent {
       }
 
       return (
-        <div className={classNames} tabIndex='0'>
+        <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
           <p
             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
-            onMouseDown={this.handleMouseDown}
-            onMouseUp={this.handleMouseUp}
           >
             <span dangerouslySetInnerHTML={spoilerContent} />
             {' '}
@@ -208,8 +206,6 @@ export default class StatusContent extends React.PureComponent {
               ref={this.setRef}
               style={directionStyle}
               tabIndex={!hidden ? 0 : null}
-              onMouseDown={this.handleMouseDown}
-              onMouseUp={this.handleMouseUp}
               dangerouslySetInnerHTML={content}
             />
             {media}
@@ -222,12 +218,12 @@ export default class StatusContent extends React.PureComponent {
         <div
           className={classNames}
           style={directionStyle}
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
           tabIndex='0'
         >
           <div
             ref={this.setRef}
-            onMouseDown={this.handleMouseDown}
-            onMouseUp={this.handleMouseUp}
             dangerouslySetInnerHTML={content}
             tabIndex='0'
           />
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index 2b35d6f3d..33bc7a959 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -1,8 +1,10 @@
+import { debounce } from 'lodash';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import LoadGap from './load_gap';
 import ScrollableList from './scrollable_list';
 import { FormattedMessage } from 'react-intl';
 
@@ -12,7 +14,7 @@ export default class StatusList extends ImmutablePureComponent {
     scrollKey: PropTypes.string.isRequired,
     statusIds: ImmutablePropTypes.list.isRequired,
     featuredStatusIds: ImmutablePropTypes.list,
-    onScrollToBottom: PropTypes.func,
+    onLoadMore: PropTypes.func,
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
@@ -50,6 +52,10 @@ export default class StatusList extends ImmutablePureComponent {
     this._selectChild(elementIndex);
   }
 
+  handleLoadOlder = debounce(() => {
+    this.props.onLoadMore(this.props.statusIds.last());
+  }, 300, { leading: true })
+
   _selectChild (index) {
     const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 
@@ -63,7 +69,7 @@ export default class StatusList extends ImmutablePureComponent {
   }
 
   render () {
-    const { statusIds, featuredStatusIds, ...other }  = this.props;
+    const { statusIds, featuredStatusIds, onLoadMore, ...other }  = this.props;
     const { isLoading, isPartial } = other;
 
     if (isPartial) {
@@ -82,7 +88,14 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     let scrollableContent = (isLoading || statusIds.size > 0) ? (
-      statusIds.map(statusId => (
+      statusIds.map((statusId, index) => statusId === null ? (
+        <LoadGap
+          key={'gap:' + statusIds.get(index + 1)}
+          disabled={isLoading}
+          maxId={index > 0 ? statusIds.get(index - 1) : null}
+          onClick={onLoadMore}
+        />
+      ) : (
         <StatusContainer
           key={statusId}
           id={statusId}
@@ -105,7 +118,7 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     return (
-      <ScrollableList {...other} ref={this.setRef}>
+      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
         {scrollableContent}
       </ScrollableList>
     );
diff --git a/app/javascript/flavours/glitch/containers/card_container.js b/app/javascript/flavours/glitch/containers/card_container.js
deleted file mode 100644
index dec7df522..000000000
--- a/app/javascript/flavours/glitch/containers/card_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Card from 'flavours/glitch/features/status/components/card';
-import { fromJS } from 'immutable';
-
-export default class CardContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string,
-    card: PropTypes.array.isRequired,
-  };
-
-  render () {
-    const { card, ...props } = this.props;
-    return <Card card={fromJS(card)} {...props} />;
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js
new file mode 100644
index 000000000..0e1904132
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/media_container.js
@@ -0,0 +1,90 @@
+import React, { PureComponent, Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import Video from 'flavours/glitch/features/video';
+import Card from 'flavours/glitch/features/status/components/card';
+import ModalRoot from 'flavours/glitch/components/modal_root';
+import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
+import { List as ImmutableList, fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card };
+
+export default class MediaContainer extends PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    components: PropTypes.object.isRequired,
+  };
+
+  state = {
+    media: null,
+    index: null,
+    time: null,
+  };
+
+  handleOpenMedia = (media, index) => {
+    document.body.classList.add('media-standalone__body');
+    this.setState({ media, index });
+  }
+
+  handleOpenVideo = (video, time) => {
+    const media = ImmutableList([video]);
+
+    document.body.classList.add('media-standalone__body');
+    this.setState({ media, time });
+  }
+
+  handleCloseMedia = () => {
+    document.body.classList.remove('media-standalone__body');
+    this.setState({ media: null, index: null, time: null });
+  }
+
+  render () {
+    const { locale, components } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Fragment>
+          {[].map.call(components, (component, i) => {
+            const componentName = component.getAttribute('data-component');
+            const Component = MEDIA_COMPONENTS[componentName];
+            const { media, card, ...props } = JSON.parse(component.getAttribute('data-props'));
+
+            Object.assign(props, {
+              ...(media ? { media: fromJS(media) } : {}),
+              ...(card  ? { card:  fromJS(card)  } : {}),
+
+              ...(componentName === 'Video' ? {
+                onOpenVideo: this.handleOpenVideo,
+              } : {
+                onOpenMedia: this.handleOpenMedia,
+              }),
+            });
+
+            return ReactDOM.createPortal(
+              <Component {...props} key={`media-${i}`} />,
+              component,
+            );
+          })}
+          <ModalRoot onClose={this.handleCloseMedia}>
+            {this.state.media && (
+              <MediaModal
+                media={this.state.media}
+                index={this.state.index || 0}
+                time={this.state.time}
+                onClose={this.handleCloseMedia}
+              />
+            )}
+          </ModalRoot>
+        </Fragment>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/media_galleries_container.js b/app/javascript/flavours/glitch/containers/media_galleries_container.js
deleted file mode 100644
index a69457882..000000000
--- a/app/javascript/flavours/glitch/containers/media_galleries_container.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from 'mastodon/locales';
-import MediaGallery from 'flavours/glitch/components/media_gallery';
-import ModalRoot from 'flavours/glitch/components/modal_root';
-import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
-import { fromJS } from 'immutable';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class MediaGalleriesContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-    galleries: PropTypes.object.isRequired,
-  };
-
-  state = {
-    media: null,
-    index: null,
-  };
-
-  handleOpenMedia = (media, index) => {
-    document.body.classList.add('media-gallery-standalone__body');
-    this.setState({ media, index });
-  }
-
-  handleCloseMedia = () => {
-    document.body.classList.remove('media-gallery-standalone__body');
-    this.setState({ media: null, index: null });
-  }
-
-  render () {
-    const { locale, galleries } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <React.Fragment>
-          {[].map.call(galleries, gallery => {
-            const { media, ...props } = JSON.parse(gallery.getAttribute('data-props'));
-
-            return ReactDOM.createPortal(
-              <MediaGallery
-                {...props}
-                media={fromJS(media)}
-                onOpenMedia={this.handleOpenMedia}
-              />,
-              gallery
-            );
-          })}
-          <ModalRoot onClose={this.handleCloseMedia}>
-            {this.state.media === null || this.state.index === null ? null : (
-              <MediaModal
-                media={this.state.media}
-                index={this.state.index}
-                onClose={this.handleCloseMedia}
-              />
-            )}
-          </ModalRoot>
-        </React.Fragment>
-      </IntlProvider>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 3fc6a6a79..acec00e12 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -5,6 +5,7 @@ import { makeGetStatus } from 'flavours/glitch/selectors';
 import {
   replyCompose,
   mentionCompose,
+  directCompose,
 } from 'flavours/glitch/actions/compose';
 import {
   reblog,
@@ -131,6 +132,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
   onMention (account, router) {
     dispatch(mentionCompose(account, router));
   },
diff --git a/app/javascript/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js
index c5ffe1b63..56669a49a 100644
--- a/app/javascript/flavours/glitch/containers/timeline_container.js
+++ b/app/javascript/flavours/glitch/containers/timeline_container.js
@@ -6,6 +6,7 @@ import { hydrateStore } from 'flavours/glitch/actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from 'mastodon/locales';
 import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline';
+import CommunityTimeline from 'flavours/glitch/features/standalone/community_timeline';
 import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline';
 import initialState from 'flavours/glitch/util/initial_state';
 
@@ -23,17 +24,24 @@ export default class TimelineContainer extends React.PureComponent {
   static propTypes = {
     locale: PropTypes.string.isRequired,
     hashtag: PropTypes.string,
+    showPublicTimeline: PropTypes.bool.isRequired,
+  };
+
+  static defaultProps = {
+    showPublicTimeline: initialState.settings.known_fediverse,
   };
 
   render () {
-    const { locale, hashtag } = this.props;
+    const { locale, hashtag, showPublicTimeline } = this.props;
 
     let timeline;
 
     if (hashtag) {
       timeline = <HashtagTimeline hashtag={hashtag} />;
-    } else {
+    } else if (showPublicTimeline) {
       timeline = <PublicTimeline />;
+    } else {
+      timeline = <CommunityTimeline />;
     }
 
     return (
diff --git a/app/javascript/flavours/glitch/containers/video_container.js b/app/javascript/flavours/glitch/containers/video_container.js
deleted file mode 100644
index b206e9a10..000000000
--- a/app/javascript/flavours/glitch/containers/video_container.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from 'mastodon/locales';
-import Video from 'flavours/glitch/features/video';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class VideoContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-  };
-
-  render () {
-    const { locale, ...props } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <Video {...props} />
-      </IntlProvider>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js
index fb90722f3..8b95c08f2 100644
--- a/app/javascript/flavours/glitch/features/account/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -8,6 +8,7 @@ import { me } from 'flavours/glitch/util/initial_state';
 
 const messages = defineMessages({
   mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -32,6 +33,7 @@ export default class ActionBar extends React.PureComponent {
     onFollow: PropTypes.func,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -53,6 +55,7 @@ export default class ActionBar extends React.PureComponent {
     let extraInfo = '';
 
     menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
 
     if ('share' in navigator) {
       menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index 7a0a2dfa9..464c73c9a 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -38,6 +38,8 @@ export default class Header extends ImmutablePureComponent {
 
     let displayName = account.get('display_name_html');
     let fields      = account.get('fields');
+    let badge       = account.get('bot') ? (<div className='roles'><div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div></div>) : null;
+
     let info        = '';
     let mutingInfo  = '';
     let actionBtn   = '';
@@ -99,38 +101,31 @@ export default class Header extends ImmutablePureComponent {
 
             <span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} />
             <span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span>
+
+            {badge}
+
             <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
 
             {fields.size > 0 && (
-              <table className='account__header__fields'>
-                <tbody>
-                  {fields.map((pair, i) => (
-                    <tr key={i}>
-                      <th dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} />
-                      <td dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                   </tr>
-                  ))}
-                </tbody>
-              </table>
+              <div className='account__header__fields'>
+                {fields.map((pair, i) => (
+                  <dl key={i}>
+                    <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+                    <dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value_plain')} />
+                 </dl>
+                ))}
+              </div>
             )}
 
             {fields.size == 0 && metadata.length && (
-              <table className='account__header__fields'>
-                <tbody>
-                  {(() => {
-                    let data = [];
-                    for (let i = 0; i < metadata.length; i++) {
-                      data.push(
-                        <tr key={i}>
-                          <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
-                          <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
-                        </tr>
-                      );
-                    }
-                    return data;
-                  })()}
-                </tbody>
-              </table>
+              <div className='account__header__fields'>
+                {metadata.map((pair, i) => (
+                  <dl key={i}>
+                    <dt dangerouslySetInnerHTML={{ __html: emojify(pair[0]) }} title={pair[0]} />
+                    <dd dangerouslySetInnerHTML={{ __html: emojify(pair[1]) }} title={pair[1]} />
+                  </dl>
+                ))}
+              </div>
             ) || null}
 
             {info}
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index 63ff98deb..ebd23a971 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { fetchAccount } from 'flavours/glitch/actions/accounts';
-import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import Column from 'flavours/glitch/features/ui/components/column';
 import ColumnBackButton from 'flavours/glitch/components/column_back_button';
@@ -17,9 +17,31 @@ import LoadMore from 'flavours/glitch/components/load_more';
 const mapStateToProps = (state, props) => ({
   medias: getAccountGallery(state, props.params.accountId),
   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
-  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
+  hasMore:   state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
 });
 
+class LoadMoreMedia extends ImmutablePureComponent {
+
+  static propTypes = {
+    maxId: PropTypes.string,
+    onLoadMore: PropTypes.func.isRequired,
+  };
+
+  handleLoadMore = () => {
+    this.props.onLoadMore(this.props.maxId);
+  }
+
+  render () {
+    return (
+      <LoadMore
+        disabled={this.props.disabled}
+        onLoadMore={this.handleLoadMore}
+      />
+    );
+  }
+
+}
+
 @connect(mapStateToProps)
 export default class AccountGallery extends ImmutablePureComponent {
 
@@ -33,19 +55,19 @@ export default class AccountGallery extends ImmutablePureComponent {
 
   componentDidMount () {
     this.props.dispatch(fetchAccount(this.props.params.accountId));
-    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
       this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
     }
   }
 
   handleScrollToBottom = () => {
     if (this.props.hasMore) {
-      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+      this.handleLoadMore(this.props.medias.last().getIn(['status', 'id']));
     }
   }
 
@@ -58,7 +80,11 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
   }
 
-  handleLoadMore = (e) => {
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
+  };
+
+  handleLoadOlder = (e) => {
     e.preventDefault();
     this.handleScrollToBottom();
   }
@@ -66,7 +92,7 @@ export default class AccountGallery extends ImmutablePureComponent {
   render () {
     const { medias, isLoading, hasMore } = this.props;
 
-    let loadMore = null;
+    let loadOlder = null;
 
     if (!medias && isLoading) {
       return (
@@ -77,7 +103,7 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
 
     if (!isLoading && medias.size > 0 && hasMore) {
-      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+      loadOlder = <LoadMore onClick={this.handleLoadOlder} />;
     }
 
     return (
@@ -89,13 +115,18 @@ export default class AccountGallery extends ImmutablePureComponent {
             <HeaderContainer accountId={this.props.params.accountId} />
 
             <div className='account-gallery__container'>
-              {medias.map(media =>
-                (<MediaItem
+              {medias.map((media, index) => media === null ? (
+                <LoadMoreMedia
+                  key={'more:' + medias.getIn(index + 1, 'id')}
+                  maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
+                />
+              ) : (
+                <MediaItem
                   key={media.get('id')}
                   media={media}
-                />)
-              )}
-              {loadMore}
+                />
+              ))}
+              {loadOlder}
             </div>
           </div>
         </ScrollContainer>
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 39a1850d7..a1434b8dd 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -16,6 +16,7 @@ export default class Header extends ImmutablePureComponent {
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -40,6 +41,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onMention(this.props.account, this.context.router.history);
   }
 
+  handleDirect = () => {
+    this.props.onDirect(this.props.account, this.context.router.history);
+  }
+
   handleReport = () => {
     this.props.onReport(this.props.account);
   }
@@ -89,6 +94,7 @@ export default class Header extends ImmutablePureComponent {
           account={account}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
+          onDirect={this.handleDirect}
           onReblogToggle={this.handleReblogToggle}
           onReport={this.handleReport}
           onMute={this.handleMute}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
index 848119c63..fb0edfa88 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
@@ -9,7 +9,10 @@ import {
   unblockAccount,
   unmuteAccount,
 } from 'flavours/glitch/actions/accounts';
-import { mentionCompose } from 'flavours/glitch/actions/compose';
+import {
+  mentionCompose,
+  directCompose
+} from 'flavours/glitch/actions/compose';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initReport } from 'flavours/glitch/actions/reports';
 import { openModal } from 'flavours/glitch/actions/modal';
@@ -67,6 +70,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(mentionCompose(account, router));
   },
 
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
   onReblogToggle (account) {
     if (account.getIn(['relationship', 'showing_reblogs'])) {
       dispatch(followAccount(account.get('id'), false));
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index fbb16dff9..2216f9153 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { fetchAccount } from 'flavours/glitch/actions/accounts';
-import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
 import Column from '../ui/components/column';
@@ -19,7 +19,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
-    hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']),
+    hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
   };
 };
 
@@ -40,22 +40,24 @@ export default class AccountTimeline extends ImmutablePureComponent {
     const { params: { accountId }, withReplies } = this.props;
 
     this.props.dispatch(fetchAccount(accountId));
-    this.props.dispatch(refreshAccountFeaturedTimeline(accountId));
-    this.props.dispatch(refreshAccountTimeline(accountId, withReplies));
+    if (!withReplies) {
+      this.props.dispatch(expandAccountFeaturedTimeline(accountId));
+    }
+    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
   }
 
   componentWillReceiveProps (nextProps) {
     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(refreshAccountFeaturedTimeline(nextProps.params.accountId));
-      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies));
+      if (!nextProps.withReplies) {
+        this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
+      }
+      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
     }
   }
 
-  handleScrollToBottom = () => {
-    if (!this.props.isLoading && this.props.hasMore) {
-      this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies));
-    }
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
   }
 
   render () {
@@ -80,7 +82,7 @@ export default class AccountTimeline extends ImmutablePureComponent {
           featuredStatusIds={featuredStatusIds}
           isLoading={isLoading}
           hasMore={hasMore}
-          onScrollToBottom={this.handleScrollToBottom}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
index dae5caf1d..9468ad81d 100644
--- a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
+++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
@@ -62,7 +62,7 @@ export default class Bookmarks extends ImmutablePureComponent {
     this.column = c;
   }
 
-  handleScrollToBottom = debounce(() => {
+  handleLoadMore = debounce(() => {
     this.props.dispatch(expandBookmarkedStatuses());
   }, 300, { leading: true })
 
@@ -89,7 +89,7 @@ export default class Bookmarks extends ImmutablePureComponent {
           scrollKey={`bookmarked_statuses-${columnId}`}
           hasMore={hasMore}
           isLoading={isLoading}
-          onScrollToBottom={this.handleScrollToBottom}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js
index 55355414f..b5843ca16 100644
--- a/app/javascript/flavours/glitch/features/community_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/community_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshCommunityTimeline,
-  expandCommunityTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class CommunityTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshCommunityTimeline());
+    dispatch(expandCommunityTimeline());
     this.disconnect = dispatch(connectCommunityStream());
   }
 
@@ -70,8 +67,8 @@ export default class CommunityTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandCommunityTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandCommunityTimeline({ maxId }));
   }
 
   render () {
@@ -97,7 +94,7 @@ export default class CommunityTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`community_timeline-${columnId}`}
           timelineId='community'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/composer/direct_warning/index.js b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js
new file mode 100644
index 000000000..d1febdd1b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  This is the spring used with our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+//  Messages.
+const messages = defineMessages({
+  disclaimer: {
+    defaultMessage: 'This toot will only be sent to all the mentioned users.',
+    id: 'compose_form.direct_message_warning',
+  },
+  learn_more: {
+    defaultMessage: 'Learn more',
+    id: 'compose_form.direct_message_warning_learn_more'
+  }
+});
+
+//  The component.
+export default function ComposerDirectWarning () {
+  return (
+    <Motion
+      defaultStyle={{
+        opacity: 0,
+        scaleX: 0.85,
+        scaleY: 0.75,
+      }}
+      style={{
+        opacity: motionSpring,
+        scaleX: motionSpring,
+        scaleY: motionSpring,
+      }}
+    >
+      {({ opacity, scaleX, scaleY }) => (
+        <div
+          className='composer--warning'
+          style={{
+            opacity: opacity,
+            transform: `scale(${scaleX}, ${scaleY})`,
+          }}
+        >
+          <span>
+            <FormattedMessage {...messages.disclaimer} /> <a href='/terms' target='_blank'><FormattedMessage {...messages.learn_more} /></a>
+          </span>
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+ComposerDirectWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
index 3aa283628..21b03be39 100644
--- a/app/javascript/flavours/glitch/features/composer/index.js
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -39,6 +39,7 @@ import ComposerTextarea from './textarea';
 import ComposerUploadForm from './upload_form';
 import ComposerWarning from './warning';
 import ComposerHashtagWarning from './hashtag_warning';
+import ComposerDirectWarning from './direct_warning';
 
 //  Utils.
 import { countableText } from 'flavours/glitch/util/counter';
@@ -55,6 +56,7 @@ function mapStateToProps (state) {
     advancedOptions: state.getIn(['compose', 'advanced_options']),
     amUnlocked: !state.getIn(['accounts', me, 'locked']),
     focusDate: state.getIn(['compose', 'focusDate']),
+    caretPosition: state.getIn(['compose', 'caretPosition']),
     isSubmitting: state.getIn(['compose', 'is_submitting']),
     isUploading: state.getIn(['compose', 'is_uploading']),
     layout: state.getIn(['local_settings', 'layout']),
@@ -116,7 +118,6 @@ const handlers = {
   handleEmoji (data) {
     const { textarea: { selectionStart } } = this;
     const { onInsertEmoji } = this.props;
-    this.caretPos = selectionStart + data.native.length + 1;
     if (onInsertEmoji) {
       onInsertEmoji(selectionStart, data);
     }
@@ -138,7 +139,6 @@ const handlers = {
   //  Selects a suggestion from the autofill.
   handleSelect (tokenStart, token, value) {
     const { onSelectSuggestion } = this.props;
-    this.caretPos = null;
     if (onSelectSuggestion) {
       onSelectSuggestion(tokenStart, token, value);
     }
@@ -190,20 +190,9 @@ class Composer extends React.Component {
     assignHandlers(this, handlers);
 
     //  Instance variables.
-    this.caretPos = null;
     this.textarea = null;
   }
 
-  //  If this is the update where we've finished uploading,
-  //  save the last caret position so we can restore it below!
-  componentWillReceiveProps (nextProps) {
-    const { textarea } = this;
-    const { isUploading } = this.props;
-    if (textarea && isUploading && !nextProps.isUploading) {
-      this.caretPos = textarea.selectionStart;
-    }
-  }
-
   //  Tells our state the composer has been mounted.
   componentDidMount () {
     const { onMount } = this.props;
@@ -227,17 +216,13 @@ class Composer extends React.Component {
   //      - Replying to more than one user, selects any usernames past
   //        the first; this provides a convenient shortcut to drop
   //        everyone else from the conversation.
-  // - If we've just finished uploading an image, and have a saved
-  //   caret position, restores the cursor to that position after the
-  //   text changes.
   componentDidUpdate (prevProps) {
     const {
-      caretPos,
       textarea,
     } = this;
     const {
       focusDate,
-      isUploading,
+      caretPosition,
       isSubmitting,
       preselectDate,
       text,
@@ -245,14 +230,14 @@ class Composer extends React.Component {
     let selectionEnd, selectionStart;
 
     //  Caret/selection handling.
-    if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
+    if (focusDate !== prevProps.focusDate) {
       switch (true) {
       case preselectDate !== prevProps.preselectDate:
         selectionStart = text.search(/\s/) + 1;
         selectionEnd = text.length;
         break;
-      case !isNaN(caretPos) && caretPos !== null:
-        selectionStart = selectionEnd = caretPos;
+      case !isNaN(caretPosition) && caretPosition !== null:
+        selectionStart = selectionEnd = caretPosition;
         break;
       default:
         selectionStart = selectionEnd = text.length;
@@ -326,6 +311,7 @@ class Composer extends React.Component {
           onSubmit={handleSubmit}
           text={spoilerText}
         />
+        {privacy === 'direct' ? <ComposerDirectWarning /> : null}
         {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
         {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
         {replyContent ? (
@@ -408,6 +394,7 @@ Composer.propTypes = {
   advancedOptions: ImmutablePropTypes.map,
   amUnlocked: PropTypes.bool,
   focusDate: PropTypes.instanceOf(Date),
+  caretPosition: PropTypes.number,
   isSubmitting: PropTypes.bool,
   isUploading: PropTypes.bool,
   layout: PropTypes.string,
diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js
index 0b8ceddee..0500a75d0 100644
--- a/app/javascript/flavours/glitch/features/composer/reply/index.js
+++ b/app/javascript/flavours/glitch/features/composer/reply/index.js
@@ -58,6 +58,7 @@ export default class ComposerReply extends React.PureComponent {
             icon='times'
             onClick={handleClick}
             title={intl.formatMessage(messages.cancel)}
+            inverted
           />
           {account ? (
             <AccountContainer
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js
index 81096c0ec..418db7c79 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshDirectTimeline,
-  expandDirectTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class DirectTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshDirectTimeline());
+    dispatch(expandDirectTimeline());
     this.disconnect = dispatch(connectDirectStream());
   }
 
@@ -70,8 +67,8 @@ export default class DirectTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandDirectTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandDirectTimeline({ maxId }));
   }
 
   render () {
@@ -97,7 +94,7 @@ export default class DirectTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`direct_timeline-${columnId}`}
           timelineId='direct'
-          loadMore={this.handleLoadMore}
+          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." />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js
index b17c47e91..3b29e2a26 100644
--- a/app/javascript/flavours/glitch/features/domain_blocks/index.js
+++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js
@@ -52,7 +52,7 @@ export default class Blocks extends ImmutablePureComponent {
     }
 
     return (
-      <Column icon='ban' heading={intl.formatMessage(messages.heading)}>
+      <Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
         <ColumnBackButtonSlim />
         <ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore}>
           {domains.map(domain =>
diff --git a/app/javascript/flavours/glitch/features/drawer/results/index.js b/app/javascript/flavours/glitch/features/drawer/results/index.js
index f2a79eb59..23dc0e3cf 100644
--- a/app/javascript/flavours/glitch/features/drawer/results/index.js
+++ b/app/javascript/flavours/glitch/features/drawer/results/index.js
@@ -68,6 +68,8 @@ export default function DrawerResults ({
           </header>
           {accounts && accounts.size ? (
             <section>
+              <h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+
               {accounts.map(
                 accountId => (
                   <AccountContainer
@@ -80,6 +82,8 @@ export default function DrawerResults ({
           ) : null}
           {statuses && statuses.size ? (
             <section>
+              <h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
               {statuses.map(
                 statusId => (
                   <StatusContainer
@@ -92,6 +96,8 @@ export default function DrawerResults ({
           ) : null}
           {hashtags && hashtags.size ? (
             <section>
+              <h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+
               {hashtags.map(
                 hashtag => (
                   <Link
diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js
index 301a5ae4f..d8fa1b84e 100644
--- a/app/javascript/flavours/glitch/features/favourited_statuses/index.js
+++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js
@@ -62,7 +62,7 @@ export default class Favourites extends ImmutablePureComponent {
     this.column = c;
   }
 
-  handleScrollToBottom = debounce(() => {
+  handleLoadMore = debounce(() => {
     this.props.dispatch(expandFavouritedStatuses());
   }, 300, { leading: true })
 
@@ -89,7 +89,7 @@ export default class Favourites extends ImmutablePureComponent {
           scrollKey={`favourited_statuses-${columnId}`}
           hasMore={hasMore}
           isLoading={isLoading}
-          onScrollToBottom={this.handleScrollToBottom}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.js b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
index 77c44c273..b67e6f97f 100644
--- a/app/javascript/flavours/glitch/features/getting_started_misc/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
@@ -50,7 +50,7 @@ export default class gettingStartedMisc extends ImmutablePureComponent {
           <ColumnLink key='20' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
           <ColumnLink key='21' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
           <ColumnLink key='22' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
-          <ColumnLink icon='ban' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
+          <ColumnLink icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
           <ColumnLink key='23' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
           <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
           <ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
index 9f3c9bec7..8f77ed42b 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { FormattedMessage } from 'react-intl';
 import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
@@ -61,13 +58,13 @@ export default class HashtagTimeline extends React.PureComponent {
     const { dispatch } = this.props;
     const { id } = this.props.params;
 
-    dispatch(refreshHashtagTimeline(id));
+    dispatch(expandHashtagTimeline(id));
     this._subscribe(dispatch, id);
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.id !== this.props.params.id) {
-      this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
+      this.props.dispatch(expandHashtagTimeline(nextProps.params.id));
       this._unsubscribe();
       this._subscribe(this.props.dispatch, nextProps.params.id);
     }
@@ -81,8 +78,8 @@ export default class HashtagTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHashtagTimeline(this.props.params.id));
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId }));
   }
 
   render () {
@@ -108,7 +105,7 @@ export default class HashtagTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`hashtag_timeline-${columnId}`}
           timelineId={`hashtag:${id}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js
index c20c0244a..3650ffc6d 100644
--- a/app/javascript/flavours/glitch/features/home_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/home_timeline/index.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { expandHomeTimeline, refreshHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
@@ -16,7 +16,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
-  isPartial: state.getIn(['timelines', 'home', 'isPartial'], false),
+  isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null,
 });
 
 @connect(mapStateToProps)
@@ -55,8 +55,8 @@ export default class HomeTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHomeTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHomeTimeline({ maxId }));
   }
 
   componentDidMount () {
@@ -78,7 +78,7 @@ export default class HomeTimeline extends React.PureComponent {
       return;
     } else if (!wasPartial && isPartial) {
       this.polling = setInterval(() => {
-        dispatch(refreshHomeTimeline());
+        dispatch(expandHomeTimeline());
       }, 3000);
     } else if (wasPartial && !isPartial) {
       this._stopPolling();
@@ -114,7 +114,7 @@ export default class HomeTimeline extends React.PureComponent {
         <StatusListContainer
           trackScroll={!pinned}
           scrollKey={`home_timeline-${columnId}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           timelineId='home'
           emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />}
         />
diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js
index f9476d92d..07edf45aa 100644
--- a/app/javascript/flavours/glitch/features/list_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.js
@@ -8,7 +8,7 @@ import ColumnHeader from 'flavours/glitch/components/column_header';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { connectListStream } from 'flavours/glitch/actions/streaming';
-import { refreshListTimeline, expandListTimeline } from 'flavours/glitch/actions/timelines';
+import { expandListTimeline } from 'flavours/glitch/actions/timelines';
 import { fetchList, deleteList } from 'flavours/glitch/actions/lists';
 import { openModal } from 'flavours/glitch/actions/modal';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
@@ -67,7 +67,7 @@ export default class ListTimeline extends React.PureComponent {
     const { id } = this.props.params;
 
     dispatch(fetchList(id));
-    dispatch(refreshListTimeline(id));
+    dispatch(expandListTimeline(id));
 
     this.disconnect = dispatch(connectListStream(id));
   }
@@ -83,9 +83,9 @@ export default class ListTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
+  handleLoadMore = maxId => {
     const { id } = this.props.params;
-    this.props.dispatch(expandListTimeline(id));
+    this.props.dispatch(expandListTimeline(id, { maxId }));
   }
 
   handleEditClick = () => {
@@ -164,7 +164,7 @@ export default class ListTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`list_timeline-${columnId}`}
           timelineId={`list:${id}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 12b0b5b83..266d6807d 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -17,6 +17,7 @@ import { createSelector } from 'reselect';
 import { List as ImmutableList } from 'immutable';
 import { debounce } from 'lodash';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import LoadGap from 'flavours/glitch/components/load_gap';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -25,14 +26,14 @@ const messages = defineMessages({
 const getNotifications = createSelector([
   state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
   state => state.getIn(['notifications', 'items']),
-], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
+], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))));
 
 const mapStateToProps = state => ({
   notifications: getNotifications(state),
   localSettings:  state.get('local_settings'),
   isLoading: state.getIn(['notifications', 'isLoading'], true),
   isUnread: state.getIn(['notifications', 'unread']) > 0,
-  hasMore: !!state.getIn(['notifications', 'next']),
+  hasMore: state.getIn(['notifications', 'hasMore']),
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
 });
 
@@ -67,9 +68,13 @@ export default class Notifications extends React.PureComponent {
     trackScroll: true,
   };
 
-  handleScrollToBottom = debounce(() => {
-    this.props.dispatch(scrollTopNotifications(false));
-    this.props.dispatch(expandNotifications());
+  handleLoadGap = (maxId) => {
+    this.props.dispatch(expandNotifications({ maxId }));
+  };
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.notifications.last();
+    this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
   }, 300, { leading: true });
 
   handleScrollToTop = debounce(() => {
@@ -104,12 +109,12 @@ export default class Notifications extends React.PureComponent {
   }
 
   handleMoveUp = id => {
-    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
     this._selectChild(elementIndex);
   }
 
   handleMoveDown = id => {
-    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
     this._selectChild(elementIndex);
   }
 
@@ -131,7 +136,14 @@ export default class Notifications extends React.PureComponent {
     if (isLoading && this.scrollableContent) {
       scrollableContent = this.scrollableContent;
     } else if (notifications.size > 0 || hasMore) {
-      scrollableContent = notifications.map((item) => (
+      scrollableContent = notifications.map((item, index) => item === null ? (
+        <LoadGap
+          key={'gap:' + notifications.getIn([index + 1, 'id'])}
+          disabled={isLoading}
+          maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
+          onClick={this.handleLoadGap}
+        />
+      ) : (
         <NotificationContainer
           key={item.get('id')}
           notification={item}
@@ -153,7 +165,7 @@ export default class Notifications extends React.PureComponent {
         isLoading={isLoading}
         hasMore={hasMore}
         emptyMessage={emptyMessage}
-        onScrollToBottom={this.handleScrollToBottom}
+        onLoadMore={this.handleLoadOlder}
         onScrollToTop={this.handleScrollToTop}
         onScroll={this.handleScroll}
         shouldUpdateScroll={shouldUpdateScroll}
diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js
index bbdd4612e..a6c0b1688 100644
--- a/app/javascript/flavours/glitch/features/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/public_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class PublicTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshPublicTimeline());
+    dispatch(expandPublicTimeline());
     this.disconnect = dispatch(connectPublicStream());
   }
 
@@ -70,8 +67,8 @@ export default class PublicTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandPublicTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandPublicTimeline({ maxId }));
   }
 
   render () {
@@ -95,7 +92,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='public'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           trackScroll={!pinned}
           scrollKey={`public_timeline-${columnId}`}
           emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
diff --git a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
new file mode 100644
index 000000000..c488f9541
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
+});
+
+@connect()
+@injectIntl
+export default class CommunityTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(expandCommunityTimeline());
+    this.disconnect = dispatch(connectCommunityStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandCommunityTimeline({ maxId }));
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='users'
+          title={intl.formatMessage(messages.title)}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          timelineId='community'
+          onLoadMore={this.handleLoadMore}
+          scrollKey='standalone_public_timeline'
+          trackScroll={false}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
index 0ad2cef80..dc02f1c91 100644
--- a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
@@ -2,12 +2,10 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
+import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
 
 @connect()
 export default class HashtagTimeline extends React.PureComponent {
@@ -28,22 +26,19 @@ export default class HashtagTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch, hashtag } = this.props;
 
-    dispatch(refreshHashtagTimeline(hashtag));
-
-    this.polling = setInterval(() => {
-      dispatch(refreshHashtagTimeline(hashtag));
-    }, 10000);
+    dispatch(expandHashtagTimeline(hashtag));
+    this.disconnect = dispatch(connectHashtagStream(hashtag));
   }
 
   componentWillUnmount () {
-    if (typeof this.polling !== 'undefined') {
-      clearInterval(this.polling);
-      this.polling = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
   }
 
   render () {
@@ -61,7 +56,7 @@ export default class HashtagTimeline extends React.PureComponent {
           trackScroll={false}
           scrollKey='standalone_hashtag_timeline'
           timelineId={`hashtag:${hashtag}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
index 717f6fcaf..0b4238485 100644
--- a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
@@ -2,13 +2,11 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
 import { defineMessages, injectIntl } from 'react-intl';
+import { connectPublicStream } from 'flavours/glitch/actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
@@ -34,22 +32,19 @@ export default class PublicTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshPublicTimeline());
-
-    this.polling = setInterval(() => {
-      dispatch(refreshPublicTimeline());
-    }, 3000);
+    dispatch(expandPublicTimeline());
+    this.disconnect = dispatch(connectPublicStream());
   }
 
   componentWillUnmount () {
-    if (typeof this.polling !== 'undefined') {
-      clearInterval(this.polling);
-      this.polling = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandPublicTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandPublicTimeline({ maxId }));
   }
 
   render () {
@@ -65,7 +60,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='public'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           scrollKey='standalone_public_timeline'
           trackScroll={false}
         />
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 1ea0fa421..ef8805377 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -8,6 +8,7 @@ import { me } from 'flavours/glitch/util/initial_state';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
@@ -43,6 +44,7 @@ export default class ActionBar extends React.PureComponent {
     onMuteConversation: PropTypes.func,
     onBlock: PropTypes.func,
     onDelete: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
     onPin: PropTypes.func,
@@ -70,6 +72,10 @@ export default class ActionBar extends React.PureComponent {
     this.props.onDelete(this.props.status);
   }
 
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  }
+
   handleMentionClick = () => {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
@@ -115,6 +121,7 @@ export default class ActionBar extends React.PureComponent {
 
     if (publicStatus) {
       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+      menu.push(null);
     }
 
     if (me === status.getIn(['account', 'id'])) {
@@ -128,6 +135,7 @@ export default class ActionBar extends React.PureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
@@ -149,7 +157,7 @@ export default class ActionBar extends React.PureComponent {
       <div className='detailed-status__action-bar'>
         <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
         <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblog_message)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
-        <div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
         {shareButton}
         <div className='detailed-status__button'><IconButton active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
 
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 16f7ae830..5cfc9dfae 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -37,8 +37,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
     e.stopPropagation();
   }
 
-  handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  handleOpenVideo = (media, startTime) => {
+    this.props.onOpenVideo(media, startTime);
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 7e1658dbb..6c9da8e3e 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -21,6 +21,7 @@ import {
 import {
   replyCompose,
   mentionCompose,
+  directCompose,
 } from 'flavours/glitch/actions/compose';
 import { blockAccount } from 'flavours/glitch/actions/accounts';
 import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
@@ -170,6 +171,10 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handleDirectClick = (account, router) => {
+    this.props.dispatch(directCompose(account, router));
+  }
+
   handleMentionClick = (account, router) => {
     this.props.dispatch(mentionCompose(account, router));
   }
@@ -399,6 +404,7 @@ export default class Status extends ImmutablePureComponent {
                   onReblog={this.handleReblogClick}
                   onBookmark={this.handleBookmarkClick}
                   onDelete={this.handleDeleteClick}
+                  onDirect={this.handleDirectClick}
                   onMention={this.handleMentionClick}
                   onMute={this.handleMuteClick}
                   onMuteConversation={this.handleConversationMuteClick}
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 6ab6770ed..bffe3b1f7 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -2,6 +2,7 @@ import React from 'react';
 import ReactSwipeableViews from 'react-swipeable-views';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
 import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player';
 import classNames from 'classnames';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -112,6 +113,22 @@ export default class MediaModal extends ImmutablePureComponent {
             onClick={this.toggleNavigation}
           />
         );
+      } else if (image.get('type') === 'video') {
+        const { time } = this.props;
+
+        return (
+          <Video
+            preview={image.get('preview_url')}
+            src={image.get('url')}
+            width={image.get('width')}
+            height={image.get('height')}
+            startTime={time || 0}
+            onCloseVideo={onClose}
+            detailed
+            description={image.get('description')}
+            key={image.get('url')}
+          />
+        );
       } else if (image.get('type') === 'gifv') {
         return (
           <ExtendedVideoPlayer
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 320c039a4..7e9980ef7 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -59,7 +59,7 @@ export default class ModalRoot extends React.PureComponent {
     const visible = !!type;
 
     return (
-      <Base onClose={onClose}>
+      <Base onClose={onClose} noEsc={props ? props.noEsc : false}>
         {visible && (
           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
             {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
index 3b7a5ff20..ff81522a8 100644
--- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import { changeReportComment, changeReportForward, submitReport } from 'flavours/glitch/actions/reports';
-import { refreshAccountTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { makeGetAccount } from 'flavours/glitch/selectors';
@@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'), true));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id'), true));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
index f85a2eeb8..e0c017f82 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -21,6 +21,8 @@ const makeGetStatusIds = () => createSelector([
   }
 
   return statusIds.filter(id => {
+    if (id === null) return true;
+
     const statusForId = statuses.get(id);
     let showStatus    = true;
 
@@ -52,18 +54,13 @@ const makeMapStateToProps = () => {
     statusIds: getStatusIds(state, { type: timelineId }),
     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
-    hasMore: !!state.getIn(['timelines', timelineId, 'next']),
+    hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
   });
 
   return mapStateToProps;
 };
 
-const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
-
-  onScrollToBottom: debounce(() => {
-    dispatch(scrollTopTimeline(timelineId, false));
-    loadMore();
-  }, 300, { leading: true }),
+const mapDispatchToProps = (dispatch, { timelineId }) => ({
 
   onScrollToTop: debounce(() => {
     dispatch(scrollTopTimeline(timelineId, true));
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index e4b69cb3b..0e3a83bb6 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -9,8 +9,8 @@ import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from 'flavours/glitch/util/is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose';
-import { refreshHomeTimeline } from 'flavours/glitch/actions/timelines';
-import { refreshNotifications } from 'flavours/glitch/actions/notifications';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandNotifications } from 'flavours/glitch/actions/notifications';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
 import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
 import UploadArea from './components/upload_area';
@@ -219,8 +219,8 @@ export default class UI extends React.Component {
       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
     }
 
-    this.props.dispatch(refreshHomeTimeline());
-    this.props.dispatch(refreshNotifications());
+    this.props.dispatch(expandHomeTimeline());
+    this.props.dispatch(expandNotifications());
   }
 
   componentDidMount () {
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index 3be6e19f7..e9e095e26 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { fromJS } from 'immutable';
 import { throttle } from 'lodash';
 import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
@@ -133,6 +134,8 @@ export default class Video extends React.PureComponent {
     this.seek = c;
   }
 
+  handleClickRoot = e => e.stopPropagation();
+
   handlePlay = () => {
     this.setState({ paused: false });
   }
@@ -246,8 +249,17 @@ export default class Video extends React.PureComponent {
   }
 
   handleOpenVideo = () => {
+    const { src, preview, width, height } = this.props;
+    const media = fromJS({
+      type: 'video',
+      url: src,
+      preview_url: preview,
+      width,
+      height,
+    });
+
     this.video.pause();
-    this.props.onOpenVideo(this.video.currentTime);
+    this.props.onOpenVideo(media, this.video.currentTime);
   }
 
   handleCloseVideo = () => {
@@ -279,7 +291,15 @@ export default class Video extends React.PureComponent {
     }
 
     return (
-      <div className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} style={playerStyle} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+      <div
+        className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })}
+        style={playerStyle}
+        ref={this.setPlayerRef}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        onClick={this.handleClickRoot}
+        tabIndex={0}
+      >
         <video
           ref={this.setVideoRef}
           src={src}
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index ed685b6b7..78e8f1053 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -6,8 +6,6 @@ function main() {
   const emojify = require('flavours/glitch/util/emoji').default;
   const { getLocale } = require('locales');
   const { localeData } = getLocale();
-  const VideoContainer = require('flavours/glitch/containers/video_container').default;
-  const CardContainer = require('flavours/glitch/containers/card_container').default;
   const React = require('react');
   const ReactDOM = require('react-dom');
 
@@ -52,24 +50,15 @@ function main() {
       });
     });
 
-    [].forEach.call(document.querySelectorAll('[data-component="Video"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<VideoContainer locale={locale} {...props} />, content);
-    });
-
-    [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
-    });
-
-    const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]');
-
-    if (mediaGalleries.length > 0) {
-      const MediaGalleriesContainer = require('flavours/glitch/containers/media_galleries_container').default;
-      const content = document.createElement('div');
-
-      ReactDOM.render(<MediaGalleriesContainer locale={locale} galleries={mediaGalleries} />, content);
-      document.body.appendChild(content);
+    const reactComponents = document.querySelectorAll('[data-component]');
+    if (reactComponents.length > 0) {
+      import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container')
+        .then(({ default: MediaContainer }) => {
+          const content = document.createElement('div');
+          ReactDOM.render(<MediaContainer locale={locale} components={reactComponents} />, content);
+          document.body.appendChild(content);
+        })
+        .catch(error => console.error(error));
     }
   });
 }
diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js
index 23fbd999c..86f4970c9 100644
--- a/app/javascript/flavours/glitch/reducers/accounts.js
+++ b/app/javascript/flavours/glitch/reducers/accounts.js
@@ -57,6 +57,12 @@ import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
 import emojify from 'flavours/glitch/util/emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import escapeTextContentForBrowser from 'escape-html';
+import { unescapeHTML } from 'flavours/glitch/util/html';
+
+const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
+  obj[`:${emoji.shortcode}:`] = emoji;
+  return obj;
+}, {});
 
 const normalizeAccount = (state, account) => {
   account = { ...account };
@@ -65,15 +71,17 @@ const normalizeAccount = (state, account) => {
   delete account.following_count;
   delete account.statuses_count;
 
+  const emojiMap = makeEmojiMap(account);
   const displayName = account.display_name.length === 0 ? account.username : account.display_name;
-  account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
-  account.note_emojified = emojify(account.note);
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
+  account.note_emojified = emojify(account.note, emojiMap);
 
   if (account.fields) {
     account.fields = account.fields.map(pair => ({
       ...pair,
       name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
-      value_emojified: emojify(pair.value),
+      value_emojified: emojify(pair.value, emojiMap),
+      value_plain: unescapeHTML(pair.value),
     }));
   }
 
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 8973c7713..24a8af86f 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -5,6 +5,7 @@ import {
   COMPOSE_CYCLE_ELEFRIEND,
   COMPOSE_REPLY,
   COMPOSE_REPLY_CANCEL,
+  COMPOSE_DIRECT,
   COMPOSE_MENTION,
   COMPOSE_SUBMIT_REQUEST,
   COMPOSE_SUBMIT_SUCCESS,
@@ -55,6 +56,7 @@ const initialState = ImmutableMap({
   privacy: null,
   text: '',
   focusDate: null,
+  caretPosition: null,
   preselectDate: null,
   in_reply_to: null,
   is_submitting: false,
@@ -147,6 +149,7 @@ function continueThread (state, status) {
     map.update('media_attachments', list => list.clear());
     map.set('idempotencyKey', uuid());
     map.set('focusDate', new Date());
+    map.set('caretPosition', null);
     map.set('preselectDate', new Date());
   });
 }
@@ -158,7 +161,6 @@ function appendMedia(state, media) {
     map.update('media_attachments', list => list.push(media));
     map.set('is_uploading', false);
     map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
-    map.set('focusDate', new Date());
     map.set('idempotencyKey', uuid());
 
     if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
@@ -186,6 +188,7 @@ const insertSuggestion = (state, position, token, completion) => {
     map.set('suggestion_token', null);
     map.update('suggestions', ImmutableList(), list => list.clear());
     map.set('focusDate', new Date());
+    map.set('caretPosition', position + completion.length + 1);
     map.set('idempotencyKey', uuid());
   });
 };
@@ -196,6 +199,7 @@ const insertEmoji = (state, position, emojiData) => {
   return state.withMutations(map => {
     map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
     map.set('focusDate', new Date());
+    map.set('caretPosition', position + emoji.length + 1);
     map.set('idempotencyKey', uuid());
   });
 };
@@ -277,6 +281,7 @@ export default function compose(state = initialState, action) {
         map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) }))
       );
       map.set('focusDate', new Date());
+      map.set('caretPosition', null);
       map.set('preselectDate', new Date());
       map.set('idempotencyKey', uuid());
 
@@ -321,10 +326,20 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_PROGRESS:
     return state.set('progress', Math.round((action.loaded / action.total) * 100));
   case COMPOSE_MENTION:
-    return state
-      .update('text', text => `${text}@${action.account.get('acct')} `)
-      .set('focusDate', new Date())
-      .set('idempotencyKey', uuid());
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_DIRECT:
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('privacy', 'direct');
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+    });
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js
index a9e3519f3..eff97fbd6 100644
--- a/app/javascript/flavours/glitch/reducers/domain_lists.js
+++ b/app/javascript/flavours/glitch/reducers/domain_lists.js
@@ -6,7 +6,9 @@ import {
 import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
 
 const initialState = ImmutableMap({
-  blocks: ImmutableMap(),
+  blocks: ImmutableMap({
+    items: ImmutableOrderedSet(),
+  }),
 });
 
 export default function domainLists(state = initialState, action) {
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index fb2b3f549..dc820b476 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -1,10 +1,7 @@
 import {
   NOTIFICATIONS_UPDATE,
-  NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS,
-  NOTIFICATIONS_REFRESH_REQUEST,
   NOTIFICATIONS_EXPAND_REQUEST,
-  NOTIFICATIONS_REFRESH_FAIL,
   NOTIFICATIONS_EXPAND_FAIL,
   NOTIFICATIONS_CLEAR,
   NOTIFICATIONS_SCROLL_TOP,
@@ -19,16 +16,16 @@ import {
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
 } from 'flavours/glitch/actions/accounts';
-import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
 
 const initialState = ImmutableMap({
   items: ImmutableList(),
-  next: null,
+  hasMore: true,
   top: true,
   unread: 0,
-  loaded: false,
-  isLoading: true,
+  isLoading: false,
   cleaningMode: false,
   // notification removal mark of new notifs loaded whilst cleaningMode is true.
   markNewForDelete: false,
@@ -58,39 +55,38 @@ const normalizeNotification = (state, notification) => {
   });
 };
 
-const normalizeNotifications = (state, notifications, next) => {
-  let items    = ImmutableList();
-  const loaded = state.get('loaded');
+const expandNormalizedNotifications = (state, notifications, next) => {
+  let items = ImmutableList();
 
   notifications.forEach((n, i) => {
     items = items.set(i, notificationToMap(state, n));
   });
 
-  if (state.get('next') === null) {
-    state = state.set('next', next);
-  }
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        const lastIndex = 1 + list.findLastIndex(
+          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
+        );
 
-  return state
-    .update('items', list => loaded ? items.concat(list) : list.concat(items))
-    .set('loaded', true)
-    .set('isLoading', false);
-};
+        const firstIndex = 1 + list.take(lastIndex).findLastIndex(
+          item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0
+        );
 
-const appendNormalizedNotifications = (state, notifications, next) => {
-  let items = ImmutableList();
+        return list.take(firstIndex).concat(items, list.skip(lastIndex));
+      });
+    }
 
-  notifications.forEach((n, i) => {
-    items = items.set(i, notificationToMap(state, n));
-  });
+    if (!next) {
+      mutable.set('hasMore', true);
+    }
 
-  return state
-    .update('items', list => list.concat(items))
-    .set('next', next)
-    .set('isLoading', false);
+    mutable.set('isLoading', false);
+  });
 };
 
 const filterNotifications = (state, relationship) => {
-  return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+  return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
 };
 
 const updateTop = (state, top) => {
@@ -102,7 +98,7 @@ const updateTop = (state, top) => {
 };
 
 const deleteByStatus = (state, statusId) => {
-  return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
+  return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId));
 };
 
 const markForDelete = (state, notificationId, yes) => {
@@ -137,29 +133,29 @@ export default function notifications(state = initialState, action) {
   let st;
 
   switch(action.type) {
-  case NOTIFICATIONS_REFRESH_REQUEST:
   case NOTIFICATIONS_EXPAND_REQUEST:
   case NOTIFICATIONS_DELETE_MARKED_REQUEST:
     return state.set('isLoading', true);
   case NOTIFICATIONS_DELETE_MARKED_FAIL:
-  case NOTIFICATIONS_REFRESH_FAIL:
   case NOTIFICATIONS_EXPAND_FAIL:
     return state.set('isLoading', false);
   case NOTIFICATIONS_SCROLL_TOP:
     return updateTop(state, action.top);
   case NOTIFICATIONS_UPDATE:
     return normalizeNotification(state, action.notification);
-  case NOTIFICATIONS_REFRESH_SUCCESS:
-    return normalizeNotifications(state, action.notifications, action.next);
   case NOTIFICATIONS_EXPAND_SUCCESS:
-    return appendNormalizedNotifications(state, action.notifications, action.next);
+    return expandNormalizedNotifications(state, action.notifications, action.next);
   case ACCOUNT_BLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
     return filterNotifications(state, action.relationship);
   case NOTIFICATIONS_CLEAR:
-    return state.set('items', ImmutableList()).set('next', null);
+    return state.set('items', ImmutableList()).set('hasMore', false);
   case TIMELINE_DELETE:
     return deleteByStatus(state, action.id);
+  case TIMELINE_DISCONNECT:
+    return action.timeline === 'home' ?
+      state.update('items', items => items.first() ? items.unshift(null) : items) :
+      state;
 
   case NOTIFICATION_MARK_FOR_DELETE:
     return markForDelete(state, action.id, action.yes);
diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js
index f9bf92098..dc6be97e2 100644
--- a/app/javascript/flavours/glitch/reducers/search.js
+++ b/app/javascript/flavours/glitch/reducers/search.js
@@ -4,7 +4,11 @@ import {
   SEARCH_FETCH_SUCCESS,
   SEARCH_SHOW,
 } from 'flavours/glitch/actions/search';
-import { COMPOSE_MENTION, COMPOSE_REPLY } from 'flavours/glitch/actions/compose';
+import {
+  COMPOSE_MENTION,
+  COMPOSE_REPLY,
+  COMPOSE_DIRECT,
+} from 'flavours/glitch/actions/compose';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
@@ -29,6 +33,7 @@ export default function search(state = initialState, action) {
     return state.set('hidden', false);
   case COMPOSE_REPLY:
   case COMPOSE_MENTION:
+  case COMPOSE_DIRECT:
     return state.set('hidden', true);
   case SEARCH_FETCH_SUCCESS:
     return state.set('results', ImmutableMap({
diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js
index c4ae2bc97..19e400b19 100644
--- a/app/javascript/flavours/glitch/reducers/timelines.js
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -1,14 +1,10 @@
 import {
-  TIMELINE_REFRESH_REQUEST,
-  TIMELINE_REFRESH_SUCCESS,
-  TIMELINE_REFRESH_FAIL,
   TIMELINE_UPDATE,
   TIMELINE_DELETE,
   TIMELINE_EXPAND_SUCCESS,
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
   TIMELINE_SCROLL_TOP,
-  TIMELINE_CONNECT,
   TIMELINE_DISCONNECT,
 } from 'flavours/glitch/actions/timelines';
 import {
@@ -17,42 +13,39 @@ import {
   ACCOUNT_UNFOLLOW_SUCCESS,
 } from 'flavours/glitch/actions/accounts';
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
 
 const initialState = ImmutableMap();
 
 const initialTimeline = ImmutableMap({
   unread: 0,
-  online: false,
   top: true,
-  loaded: false,
   isLoading: false,
-  next: false,
+  hasMore: true,
   items: ImmutableList(),
 });
 
-const normalizeTimeline = (state, timeline, statuses, next, isPartial) => {
-  const oldIds    = state.getIn([timeline, 'items'], ImmutableList());
-  const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
-  const wasLoaded = state.getIn([timeline, 'loaded']);
-  const hadNext   = state.getIn([timeline, 'next']);
-
-  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
-    mMap.set('loaded', true);
-    mMap.set('isLoading', false);
-    if (!hadNext) mMap.set('next', next);
-    mMap.set('items', wasLoaded ? ids.concat(oldIds) : oldIds.concat(ids));
-    mMap.set('isPartial', isPartial);
-  }));
-};
-
-const appendNormalizedTimeline = (state, timeline, statuses, next) => {
-  const oldIds = state.getIn([timeline, 'items'], ImmutableList());
-  const ids    = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
-
+const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) => {
   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
     mMap.set('isLoading', false);
-    mMap.set('next', next);
-    mMap.set('items', oldIds.concat(ids));
+    if (!next) mMap.set('hasMore', false);
+
+    if (!statuses.isEmpty()) {
+      mMap.update('items', ImmutableList(), oldIds => {
+        const newIds = statuses.map(status => status.get('id'));
+        const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
+
+        if (firstIndex < 0) {
+          return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
+        }
+
+        return oldIds.take(firstIndex + 1).concat(
+          isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds,
+          oldIds.skip(lastIndex)
+        );
+      });
+    }
   }));
 };
 
@@ -119,16 +112,12 @@ const updateTop = (state, timeline, top) => {
 
 export default function timelines(state = initialState, action) {
   switch(action.type) {
-  case TIMELINE_REFRESH_REQUEST:
   case TIMELINE_EXPAND_REQUEST:
     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
-  case TIMELINE_REFRESH_FAIL:
   case TIMELINE_EXPAND_FAIL:
     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
-  case TIMELINE_REFRESH_SUCCESS:
-    return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial);
   case TIMELINE_EXPAND_SUCCESS:
-    return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+    return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial);
   case TIMELINE_UPDATE:
     return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
   case TIMELINE_DELETE:
@@ -140,10 +129,15 @@ export default function timelines(state = initialState, action) {
     return filterTimeline('home', state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
     return updateTop(state, action.timeline, action.top);
-  case TIMELINE_CONNECT:
-    return state.update(action.timeline, initialTimeline, map => map.set('online', true));
   case TIMELINE_DISCONNECT:
-    return state.update(action.timeline, initialTimeline, map => map.set('online', false));
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.update(
+        'items',
+        items => items.first() ? items.unshift(null) : items
+      )
+    );
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/service_worker/entry.js b/app/javascript/flavours/glitch/service_worker/entry.js
deleted file mode 100644
index eea4cfc3c..000000000
--- a/app/javascript/flavours/glitch/service_worker/entry.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import './web_push_notifications';
-
-// Cause a new version of a registered Service Worker to replace an existing one
-// that is already installed, and replace the currently active worker on open pages.
-self.addEventListener('install', function(event) {
-  event.waitUntil(self.skipWaiting());
-});
-self.addEventListener('activate', function(event) {
-  event.waitUntil(self.clients.claim());
-});
diff --git a/app/javascript/flavours/glitch/service_worker/web_push_notifications.js b/app/javascript/flavours/glitch/service_worker/web_push_notifications.js
deleted file mode 100644
index f63cff335..000000000
--- a/app/javascript/flavours/glitch/service_worker/web_push_notifications.js
+++ /dev/null
@@ -1,159 +0,0 @@
-const MAX_NOTIFICATIONS = 5;
-const GROUP_TAG = 'tag';
-
-// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
-const formatGroupTitle = (message, count) => message.replace('%{count}', count);
-
-const notify = options =>
-  self.registration.getNotifications().then(notifications => {
-    if (notifications.length === MAX_NOTIFICATIONS) {
-      // Reached the maximum number of notifications, proceed with grouping
-      const group = {
-        title: formatGroupTitle(options.data.message, notifications.length + 1),
-        body: notifications
-          .sort((n1, n2) => n1.timestamp < n2.timestamp)
-          .map(notification => notification.title).join('\n'),
-        badge: '/badge.png',
-        icon: '/android-chrome-192x192.png',
-        tag: GROUP_TAG,
-        data: {
-          url: (new URL('/web/notifications', self.location)).href,
-          count: notifications.length + 1,
-          message: options.data.message,
-        },
-      };
-
-      notifications.forEach(notification => notification.close());
-
-      return self.registration.showNotification(group.title, group);
-    } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
-      // Already grouped, proceed with appending the notification to the group
-      const group = cloneNotification(notifications[0]);
-
-      group.title = formatGroupTitle(group.data.message, group.data.count + 1);
-      group.body  = `${options.title}\n${group.body}`;
-      group.data  = { ...group.data, count: group.data.count + 1 };
-
-      return self.registration.showNotification(group.title, group);
-    }
-
-    return self.registration.showNotification(options.title, options);
-  });
-
-const handlePush = (event) => {
-  const options = event.data.json();
-
-  options.body      = options.data.nsfw || options.data.content;
-  options.dir       = options.data.dir;
-  options.image     = options.image || undefined; // Null results in a network request (404)
-  options.timestamp = options.timestamp && new Date(options.timestamp);
-
-  const expandAction = options.data.actions.find(action => action.todo === 'expand');
-
-  if (expandAction) {
-    options.actions          = [expandAction];
-    options.hiddenActions    = options.data.actions.filter(action => action !== expandAction);
-    options.data.hiddenImage = options.image;
-    options.image            = undefined;
-  } else {
-    options.actions = options.data.actions;
-  }
-
-  event.waitUntil(notify(options));
-};
-
-const cloneNotification = (notification) => {
-  const clone = {  };
-
-  for(var k in notification) {
-    clone[k] = notification[k];
-  }
-
-  return clone;
-};
-
-const expandNotification = (notification) => {
-  const nextNotification = cloneNotification(notification);
-
-  nextNotification.body    = notification.data.content;
-  nextNotification.image   = notification.data.hiddenImage;
-  nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
-
-  return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
-const makeRequest = (notification, action) =>
-  fetch(action.action, {
-    headers: {
-      'Authorization': `Bearer ${notification.data.access_token}`,
-      'Content-Type': 'application/json',
-    },
-    method: action.method,
-    credentials: 'include',
-  });
-
-const findBestClient = clients => {
-  const focusedClient = clients.find(client => client.focused);
-  const visibleClient = clients.find(client => client.visibilityState === 'visible');
-
-  return focusedClient || visibleClient || clients[0];
-};
-
-const openUrl = url =>
-  self.clients.matchAll({ type: 'window' }).then(clientList => {
-    if (clientList.length !== 0) {
-      const webClients = clientList.filter(client => /\/web\//.test(client.url));
-
-      if (webClients.length !== 0) {
-        const client       = findBestClient(webClients);
-        const { pathname } = new URL(url);
-
-        if (pathname.startsWith('/web/')) {
-          return client.focus().then(client => client.postMessage({
-            type: 'navigate',
-            path: pathname.slice('/web/'.length - 1),
-          }));
-        }
-      } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
-        const client = findBestClient(clientList);
-
-        return client.navigate(url).then(client => client.focus());
-      }
-    }
-
-    return self.clients.openWindow(url);
-  });
-
-const removeActionFromNotification = (notification, action) => {
-  const actions          = notification.actions.filter(act => act.action !== action.action);
-  const nextNotification = cloneNotification(notification);
-
-  nextNotification.actions = actions;
-
-  return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
-const handleNotificationClick = (event) => {
-  const reactToNotificationClick = new Promise((resolve, reject) => {
-    if (event.action) {
-      const action = event.notification.data.actions.find(({ action }) => action === event.action);
-
-      if (action.todo === 'expand') {
-        resolve(expandNotification(event.notification));
-      } else if (action.todo === 'request') {
-        resolve(makeRequest(event.notification, action)
-          .then(() => removeActionFromNotification(event.notification, action)));
-      } else {
-        reject(`Unknown action: ${action.todo}`);
-      }
-    } else {
-      event.notification.close();
-      resolve(openUrl(event.notification.data.url));
-    }
-  });
-
-  event.waitUntil(reactToNotificationClick);
-};
-
-self.addEventListener('push', handlePush);
-self.addEventListener('notificationclick', handleNotificationClick);
diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss
index 55f31266f..c9c0e3081 100644
--- a/app/javascript/flavours/glitch/styles/about.scss
+++ b/app/javascript/flavours/glitch/styles/about.scss
@@ -322,6 +322,11 @@ $small-breakpoint: 960px;
     border: 0;
     border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
     margin: 20px 0;
+
+    &.spacer {
+      height: 1px;
+      border: 0;
+    }
   }
 
   .container-alt {
@@ -681,6 +686,54 @@ $small-breakpoint: 960px;
       margin-bottom: 0;
     }
 
+    .account {
+      border-bottom: 0;
+      padding: 0;
+
+      &__display-name {
+        align-items: center;
+        display: flex;
+        margin-right: 5px;
+      }
+
+      div.account__display-name {
+        &:hover {
+          .display-name strong {
+            text-decoration: none;
+          }
+        }
+
+        .account__avatar {
+          cursor: default;
+        }
+      }
+
+      &__avatar-wrapper {
+        margin-left: 0;
+        flex: 0 0 auto;
+      }
+
+      &__avatar {
+        width: 44px;
+        height: 44px;
+        background-size: 44px 44px;
+      }
+
+      .display-name {
+        font-size: 15px;
+
+        &__account {
+          font-size: 14px;
+        }
+      }
+    }
+
+    @media screen and (max-width: $small-breakpoint) {
+      .contact {
+        margin-top: 30px;
+      }
+    }
+
     @media screen and (max-width: $column-breakpoint) {
       padding: 25px 20px;
     }
@@ -816,6 +869,8 @@ $small-breakpoint: 960px;
       font-size: 16px;
       line-height: inherit;
       font-weight: inherit;
+      margin: 0;
+      padding: 0;
     }
 
     .column {
@@ -852,8 +907,13 @@ $small-breakpoint: 960px;
   }
 
   &__features {
+    & > p {
+      padding-right: 60px;
+    }
+
     .features-list {
-      margin: 40px 0 !important;
+      margin: 40px 0;
+      margin-top: 30px;
     }
 
     &__action {
@@ -862,17 +922,11 @@ $small-breakpoint: 960px;
   }
 
   .features-list {
-    margin-top: 20px;
-
     .features-list__row {
       display: flex;
       padding: 10px 0;
       justify-content: space-between;
 
-      &:first-child {
-        padding-top: 0;
-      }
-
       .visual {
         flex: 0 0 auto;
         display: flex;
@@ -898,6 +952,14 @@ $small-breakpoint: 960px;
         }
       }
     }
+
+    @media screen and (min-width: $small-breakpoint) {
+      display: grid;
+      grid-gap: 30px;
+      grid-template-columns: 1fr 1fr;
+      grid-auto-columns: 50%;
+      grid-auto-rows: max-content;
+    }
   }
 
   .extended-description {
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index eff964e50..efff59ff6 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -326,6 +326,15 @@
   z-index: 2;
   position: relative;
 
+  &.empty img {
+    position: absolute;
+    opacity: 0.2;
+    height: 200px;
+    left: 0;
+    bottom: 0;
+    pointer-events: none;
+  }
+
   @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
@@ -445,8 +454,8 @@
   font-size: 14px;
   font-weight: 500;
   text-align: center;
-  padding: 60px 0;
-  padding-top: 55px;
+  padding: 130px 0;
+  padding-top: 125px;
   margin: 0 auto;
   cursor: default;
 }
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index b077df145..7fe5e4a19 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -141,14 +141,15 @@
     }
 
     hr {
-      margin: 20px 0;
+      width: 100%;
+      height: 0;
       border: 0;
-      background: transparent;
-      border-bottom: 1px solid $ui-base-color;
+      border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+      margin: 20px 0;
 
-      &.section-break {
-        margin: 30px 0;
-        border-bottom: 2px solid $ui-base-lighter-color;
+      &.spacer {
+        height: 1px;
+        border: 0;
       }
     }
 
@@ -351,34 +352,9 @@
   }
 }
 
-.report-note__comment {
-  margin-bottom: 20px;
-}
-
-.report-note__form {
-  margin-bottom: 20px;
-
-  .report-note__textarea {
-    box-sizing: border-box;
-    border: 0;
-    padding: 7px 4px;
-    margin-bottom: 10px;
-    font-size: 16px;
-    color: $inverted-text-color;
-    display: block;
-    width: 100%;
-    outline: 0;
-    font-family: inherit;
-    resize: vertical;
-  }
-
-  .report-note__buttons {
-    text-align: right;
-  }
-
-  .report-note__button {
-    margin: 0 0 5px 5px;
-  }
+.simple_form.new_report_note,
+.simple_form.new_account_moderation_note {
+  max-width: 100%;
 }
 
 .batch-form-box {
@@ -406,13 +382,6 @@
   }
 }
 
-.batch-checkbox,
-.batch-checkbox-all {
-  display: flex;
-  align-items: center;
-  margin-right: 5px;
-}
-
 .back-link {
   margin-bottom: 10px;
   font-size: 14px;
@@ -432,7 +401,7 @@
 }
 
 .log-entry {
-  margin-bottom: 8px;
+  margin-bottom: 20px;
   line-height: 20px;
 
   &__header {
@@ -530,6 +499,31 @@
   }
 }
 
+a.name-tag,
+.name-tag,
+a.inline-name-tag,
+.inline-name-tag {
+  text-decoration: none;
+  color: $secondary-text-color;
+
+  .username {
+    font-weight: 500;
+  }
+
+  &.suspended {
+    .username {
+      text-decoration: line-through;
+      color: lighten($error-red, 12%);
+    }
+
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
+a.name-tag,
 .name-tag {
   display: flex;
   align-items: center;
@@ -541,7 +535,46 @@
     border-radius: 50%;
   }
 
-  .username {
+  &.suspended {
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
+.speech-bubble {
+  margin-bottom: 20px;
+  border-left: 4px solid $ui-highlight-color;
+
+  &.positive {
+    border-left-color: $success-green;
+  }
+
+  &.negative {
+    border-left-color: lighten($error-red, 12%);
+  }
+
+  &__bubble {
+    padding: 16px;
+    padding-left: 14px;
+    font-size: 15px;
+    line-height: 20px;
+    border-radius: 4px 4px 4px 0;
+    position: relative;
     font-weight: 500;
+
+    a {
+      color: $darker-text-color;
+    }
+  }
+
+  &__owner {
+    padding: 8px;
+    padding-left: 12px;
+  }
+
+  time {
+    color: $dark-text-color;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 84d3f6ade..dadfa6d57 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -32,7 +32,8 @@
 
 .account__avatar-wrapper {
   float: left;
-  margin: 6px 16px 6px 6px;
+  margin-left: 12px;
+  margin-right: 12px;
 }
 
 .account__avatar {
@@ -509,3 +510,9 @@
     margin-bottom: 0;
   }
 }
+
+.account__header .roles {
+  margin-top: 20px;
+  margin-bottom: 20px;
+  padding: 0 15px;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index df239dba7..0432b233a 100644
--- a/app/javascript/flavours/glitch/styles/components/drawer.scss
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -266,6 +266,40 @@
 
   & > section {
     background: $ui-base-color;
+    margin-bottom: 20px;
+
+    h5 {
+      position: relative;
+
+      &::before {
+        content: "";
+        display: block;
+        position: absolute;
+        left: 0;
+        right: 0;
+        top: 50%;
+        width: 100%;
+        height: 0;
+        border-top: 1px solid lighten($ui-base-color, 8%);
+      }
+
+      span {
+        display: inline-block;
+        background: $ui-base-color;
+        color: $darker-text-color;
+        font-size: 14px;
+        font-weight: 500;
+        padding: 10px;
+        position: relative;
+        z-index: 1;
+        cursor: default;
+      }
+    }
+
+    .account:last-child,
+    & > div:last-child .status {
+      border-bottom: 0;
+    }
 
     & > .hashtag {
       display: block;
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 6f3338605..0fa940766 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -848,6 +848,10 @@
   }
 }
 
+.load-gap {
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
 .missing-indicator {
   padding-top: 20px + 48px;
 
@@ -894,7 +898,7 @@
     width: 30px;
     height: 30px;
     font-size: 20px;
-    color: $inverted-text-color;
+    color: $darker-text-color;
     text-shadow: 0 0 5px black;
     display: flex;
     justify-content: center;
diff --git a/app/javascript/flavours/glitch/styles/components/local_settings.scss b/app/javascript/flavours/glitch/styles/components/local_settings.scss
index 9e1606e99..9cd4e1fbe 100644
--- a/app/javascript/flavours/glitch/styles/components/local_settings.scss
+++ b/app/javascript/flavours/glitch/styles/components/local_settings.scss
@@ -35,8 +35,8 @@
   display: block;
   padding: 15px 20px;
   color: inherit;
-  background: $primary-text-color;
-  border-bottom: 1px $ui-primary-color solid;
+  background: lighten($ui-secondary-color, 8%);
+  border-bottom: 1px $ui-secondary-color solid;
   cursor: pointer;
   text-decoration: none;
   outline: none;
@@ -58,8 +58,7 @@
 }
 
 .glitch.local-settings__navigation {
-  background: $simple-background-color;
-  color: $inverted-text-color;
+  background: lighten($ui-secondary-color, 8%);
   width: 200px;
   font-size: 15px;
   line-height: 20px;
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 90674612d..5a49c07fa 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -279,6 +279,10 @@
   background: $base-shadow-color;
   max-width: 100%;
 
+  &:focus {
+    outline: 0;
+  }
+
   .detailed-status & {
     width: 100%;
     height: 100%;
diff --git a/app/javascript/flavours/glitch/styles/components/metadata.scss b/app/javascript/flavours/glitch/styles/components/metadata.scss
index fa1a4bc34..29a6330e9 100644
--- a/app/javascript/flavours/glitch/styles/components/metadata.scss
+++ b/app/javascript/flavours/glitch/styles/components/metadata.scss
@@ -2,7 +2,6 @@
   font-size: 15px;
   line-height: 20px;
   overflow: hidden;
-  border-collapse: collapse;
   margin: 20px -10px -20px;
   border-bottom: 0;
 
@@ -14,35 +13,36 @@
     }
   }
 
-  tr {
+  dl {
     border-top: 1px solid lighten($ui-base-color, 8%);
-    text-align: center;
+    display: flex;
   }
 
-  th, td {
+  dt,
+  dd {
+    box-sizing: border-box;
     padding: 14px 20px;
-    vertical-align: middle;
-
-    & > div {
-      max-height: 40px;
-      overflow-y: auto;
-      white-space: pre-wrap;
-      text-overflow: ellipsis;
-    }
+    text-align: center;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     color: $darker-text-color;
     background: lighten($ui-base-color, 13%);
-    max-width: 120px;
+    width: 120px;
+    flex: 0 0 auto;
+    font-weight: 500;
 
     a {
       color: $primary-text-color;
     }
   }
 
-  td {
-    flex: auto;
+  dd {
+    flex: 1 1 auto;
     color: $primary-text-color;
     background: $ui-base-color;
 
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 9d5ab66a4..ac648c868 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -60,7 +60,7 @@
   }
 }
 
-.media-gallery-standalone__body {
+.media-standalone__body {
   overflow: hidden;
 }
 
diff --git a/app/javascript/flavours/glitch/styles/footer.scss b/app/javascript/flavours/glitch/styles/footer.scss
index ba2a06954..fe2d40c0c 100644
--- a/app/javascript/flavours/glitch/styles/footer.scss
+++ b/app/javascript/flavours/glitch/styles/footer.scss
@@ -4,7 +4,7 @@
   font-size: 12px;
   color: $darker-text-color;
 
-  .domain {
+  .footer__domain {
     font-weight: 500;
 
     a {
@@ -26,5 +26,13 @@
         text-decoration: none;
       }
     }
+
+    img {
+      margin: 0 4px;
+      position: relative;
+      bottom: -1px;
+      height: 18px;
+      vertical-align: top;
+    }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 0b12742a9..f97890187 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -280,6 +280,11 @@ code {
   .actions {
     margin-top: 30px;
     display: flex;
+
+    &.actions--top {
+      margin-top: 0;
+      margin-bottom: 30px;
+    }
   }
 
   button,
@@ -563,9 +568,27 @@ code {
 
 .post-follow-actions {
   text-align: center;
-  color: $ui-primary-color;
+  color: $darker-text-color;
 
   div {
     margin-bottom: 4px;
   }
 }
+
+.alternative-login {
+  margin-top: 20px;
+  margin-bottom: 20px;
+
+  h4 {
+    font-size: 16px;
+    color: $primary-text-color;
+    text-align: center;
+    margin-bottom: 20px;
+    border: 0;
+    padding: 0;
+  }
+
+  .button {
+    display: block;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light.scss b/app/javascript/flavours/glitch/styles/mastodon-light.scss
new file mode 100644
index 000000000..029d5dde2
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/mastodon-light.scss
@@ -0,0 +1,218 @@
+// Set variables
+$ui-base-color: #d9e1e8;
+$ui-base-lighter-color: darken($ui-base-color, 57%);
+$ui-highlight-color: #2b90d9;
+$ui-primary-color: darken($ui-highlight-color, 28%);
+$ui-secondary-color: #282c37;
+
+$primary-text-color: black;
+$base-overlay-background: $ui-base-color;
+
+$login-button-color: white;
+$account-background-color: white;
+
+// Import defaults
+@import 'index';
+
+// Change the color of the log in button
+.button {
+  &.button-alternative-2 {
+    color: $login-button-color;
+  }
+}
+
+// Change columns' default background colors
+.column {
+  > .scrollable {
+    background: lighten($ui-base-color, 13%);
+  }
+}
+
+.status.collapsed .status__content:after {
+  background: linear-gradient(rgba(lighten($ui-base-color, 13%), 0), rgba(lighten($ui-base-color, 13%), 1));
+}
+
+.drawer__inner {
+  background: $ui-base-color;
+}
+
+.drawer > .contents {
+  background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color(lighten($ui-base-color, 13%))}"/></svg>') no-repeat bottom / 100% auto !important;
+
+  .mastodon {
+    filter: contrast(75%) brightness(75%) !important;
+  }
+}
+
+// Change the default appearance of the content warning button
+.status__content {
+
+  .status__content__spoiler-link {
+
+    background: darken($ui-base-color, 30%);
+
+    &:hover {
+      background: darken($ui-base-color, 35%);
+      text-decoration: none;
+    }
+
+  }
+
+}
+
+// Change the default appearance of the action buttons
+.icon-button {
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: darken($ui-base-color, 40%);
+    transition: color 200ms ease-out;
+  }
+
+  &.disabled {
+    color: darken($ui-base-color, 30%);
+  }
+
+}
+
+.status {
+  &.status-direct {
+    .icon-button.disabled {
+      color: darken($ui-base-color, 30%);
+    }
+  }
+}
+
+// Change the colors used in the dropdown menu
+.dropdown-menu {
+  background: $ui-base-color;
+}
+
+.dropdown-menu__arrow {
+
+  &.left {
+    border-left-color: $ui-base-color;
+  }
+
+  &.top {
+    border-top-color: $ui-base-color;
+  }
+
+  &.bottom {
+    border-bottom-color: $ui-base-color;
+  }
+
+  &.right {
+    border-right-color: $ui-base-color;
+  }
+
+}
+
+.dropdown-menu__item {
+  a {
+    background: $ui-base-color;
+    color: $ui-secondary-color;
+  }
+}
+
+// Change the default color of several parts of the compose form
+.composer {
+
+  .composer--spoiler input, .composer--textarea textarea {
+    color: darken($ui-base-color, 80%);
+
+    &:disabled { background: darken($simple-background-color, 10%) }
+
+    &::placeholder {
+      color: darken($ui-base-color, 70%);
+    }
+  }
+
+  strong {
+    color: lighten($ui-secondary-color, 65%);
+  }
+
+  .composer--options {
+    background: darken($ui-base-color, 10%);
+    box-shadow: unset;
+  }
+
+  .composer--options--dropdown--content--item {
+    color: $ui-primary-color;
+    
+    strong {
+      color: $ui-primary-color;
+    }
+
+  }
+
+}
+
+// Change the default color used for the text in an empty column or on the error column
+.empty-column-indicator,
+.error-column {
+  color: darken($ui-base-color, 60%);
+}
+
+// Change the default colors used on some parts of the profile pages
+.activity-stream-tabs {
+
+  background: $account-background-color;
+
+  a {
+    &.active {
+      color: $ui-primary-color;
+      }
+  }
+
+}
+
+.activity-stream {
+
+  .entry {
+    background: $account-background-color;
+  }
+
+  .status.light {
+
+    .status__content {
+      color: $primary-text-color;
+    }
+
+    .display-name {
+      strong {
+        color: $primary-text-color;
+      }
+    }
+
+  }
+
+}
+
+.accounts-grid {
+  .account-grid-card {
+
+    .controls {
+      .icon-button {
+        color: $ui-secondary-color;
+      }
+    }
+
+    .name {
+      a {
+        color: $primary-text-color;
+      }
+    }
+
+    .username {
+      color: $ui-secondary-color;
+    }
+
+    .account__header__content {
+      color: $primary-text-color;
+    }
+
+  }
+}
+
diff --git a/app/javascript/flavours/glitch/styles/metadata.scss b/app/javascript/flavours/glitch/styles/metadata.scss
index b66cce3c1..280848959 100644
--- a/app/javascript/flavours/glitch/styles/metadata.scss
+++ b/app/javascript/flavours/glitch/styles/metadata.scss
@@ -1,43 +1,56 @@
 .account__header__fields {
   $meta-table-border: lighten($ui-base-color, 8%);
-
-  border-collapse: collapse;
   padding: 0;
   margin: 15px -15px -15px -15px;
   border: 0 none;
   border-top: 1px solid $meta-table-border;
   border-bottom: 1px solid $meta-table-border;
+  font-size: 14px;
+  line-height: 20px;
 
-  td, th {
-    padding: 15px;
-    border: 0 none;
+  dl {
+    display: flex;
     border-bottom: 1px solid $meta-table-border;
-    vertical-align: middle;
   }
 
-  tr:last-child {
-    td, th {
-      border-bottom: 0 none;
-    }
-  }
-
-  td {
-    color: $ui-primary-color;
+  dt,
+  dd {
+    box-sizing: border-box;
+    padding: 14px;
     text-align: center;
-    width:100%;
-    padding-left: 0;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     padding-left: 15px;
-    font-weight: bold;
+    font-weight: 500;
     text-align: center;
-    width: 94px;
-    color: $ui-secondary-color;
+    width: 120px;
+    flex: 0 0 auto;
+    color: $secondary-text-color;
     background: darken($ui-base-color, 8%);
   }
 
+  dd {
+    flex: 1 1 auto;
+    color: $darker-text-color;
+  }
+
   a {
-    color: $classic-highlight-color;
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+
+  dl:last-child {
+    border-bottom: 0;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
index 77420c84b..e9099a9e9 100644
--- a/app/javascript/flavours/glitch/styles/rtl.scss
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -1,6 +1,22 @@
 body.rtl {
   direction: rtl;
 
+  .column-header > button {
+    text-align: right;
+    padding-left: 0;
+    padding-right: 15px;
+  }
+
+  .landing-page__logo {
+    margin-right: 0;
+    margin-left: 20px;
+  }
+
+  .landing-page .features-list .features-list__row .visual {
+    margin-left: 0;
+    margin-right: 15px;
+  }
+
   .column-link__icon,
   .column-header__icon {
     margin-right: 0;
diff --git a/app/javascript/flavours/glitch/styles/stream_entries.scss b/app/javascript/flavours/glitch/styles/stream_entries.scss
index b505c1580..40963ae84 100644
--- a/app/javascript/flavours/glitch/styles/stream_entries.scss
+++ b/app/javascript/flavours/glitch/styles/stream_entries.scss
@@ -6,7 +6,8 @@
     background: $simple-background-color;
 
     .detailed-status.light,
-    .status.light {
+    .status.light,
+    .more.light {
       border-bottom: 1px solid $ui-secondary-color;
       animation: none;
     }
@@ -65,6 +66,10 @@
     }
   }
 
+  .media-gallery__gifv__label {
+    bottom: 9px;
+  }
+
   .status.light {
     padding: 14px 14px 14px (48px + 14px * 2);
     position: relative;
@@ -145,10 +150,10 @@
 
       a.status__content__spoiler-link {
         color: $primary-text-color;
-        background: $ui-primary-color;
+        background: $ui-base-color;
 
         &:hover {
-          background: lighten($ui-primary-color, 8%);
+          background: lighten($ui-base-color, 8%);
         }
       }
     }
@@ -215,10 +220,10 @@
 
       a.status__content__spoiler-link {
         color: $primary-text-color;
-        background: $ui-primary-color;
+        background: $ui-base-color;
 
         &:hover {
-          background: lighten($ui-primary-color, 8%);
+          background: lighten($ui-base-color, 8%);
         }
       }
     }
@@ -261,16 +266,8 @@
   }
 
   .media-spoiler {
-    background: $ui-primary-color;
-    color: $inverted-text-color;
-    transition: all 100ms linear;
-
-    &:hover,
-    &:active,
-    &:focus {
-      background: darken($ui-primary-color, 5%);
-      color: unset;
-    }
+    background: $ui-base-color;
+    color: $darker-text-color;
   }
 
   .pre-header {
@@ -299,6 +296,17 @@
       text-decoration: underline;
     }
   }
+
+  .more {
+    color: $darker-text-color;
+    display: block;
+    padding: 14px;
+    text-align: center;
+
+    &:not(:hover) {
+      text-decoration: none;
+    }
+  }
 }
 
 .embed {
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
index c12d84f1c..982bfd990 100644
--- a/app/javascript/flavours/glitch/styles/tables.scss
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -1,3 +1,9 @@
+@keyframes Swag {
+  0% { background-position: 0% 0%; }
+  50% { background-position: 100% 0%; }
+  100% { background-position: 200% 0%; }
+}
+
 .table {
   width: 100%;
   max-width: 100%;
@@ -11,6 +17,7 @@
     vertical-align: top;
     border-top: 1px solid $ui-base-color;
     text-align: left;
+    background: darken($ui-base-color, 4%);
   }
 
   & > thead > tr > th {
@@ -48,9 +55,38 @@
     }
   }
 
-  &.inline-table > tbody > tr:nth-child(odd) > td,
-  &.inline-table > tbody > tr:nth-child(odd) > th {
-    background: transparent;
+  &.inline-table {
+    & > tbody > tr:nth-child(odd) {
+      & > td,
+      & > th {
+        background: transparent;
+      }
+    }
+
+    & > tbody > tr:first-child {
+      & > td,
+      & > th {
+        border-top: 0;
+      }
+    }
+  }
+
+  &.batch-table {
+    & > thead > tr > th {
+      background: $ui-base-color;
+      border-top: 1px solid darken($ui-base-color, 8%);
+      border-bottom: 1px solid darken($ui-base-color, 8%);
+
+      &:first-child {
+        border-radius: 4px 0 0;
+        border-left: 1px solid darken($ui-base-color, 8%);
+      }
+
+      &:last-child {
+        border-radius: 0 4px 0 0;
+        border-right: 1px solid darken($ui-base-color, 8%);
+      }
+    }
   }
 }
 
@@ -63,6 +99,13 @@ samp {
   font-family: 'mastodon-font-monospace', monospace;
 }
 
+button.table-action-link {
+  background: transparent;
+  border: 0;
+  font: inherit;
+}
+
+button.table-action-link,
 a.table-action-link {
   text-decoration: none;
   display: inline-block;
@@ -79,4 +122,82 @@ a.table-action-link {
     font-weight: 400;
     margin-right: 5px;
   }
+
+  &:first-child {
+    padding-left: 0;
+  }
+}
+
+.batch-table {
+  &__toolbar,
+  &__row {
+    display: flex;
+
+    &__select {
+      box-sizing: border-box;
+      padding: 8px 16px;
+      cursor: pointer;
+      min-height: 100%;
+
+      input {
+        margin-top: 8px;
+      }
+    }
+
+    &__actions,
+    &__content {
+      padding: 8px 0;
+      padding-right: 16px;
+      flex: 1 1 auto;
+    }
+  }
+
+  &__toolbar {
+    border: 1px solid darken($ui-base-color, 8%);
+    background: $ui-base-color;
+    border-radius: 4px 0 0;
+    height: 47px;
+    align-items: center;
+
+    &__actions {
+      text-align: right;
+      padding-right: 16px - 5px;
+    }
+  }
+
+  &__row {
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    background: darken($ui-base-color, 4%);
+
+    &:hover {
+      background: darken($ui-base-color, 2%);
+    }
+
+    &:nth-child(even) {
+      background: $ui-base-color;
+
+      &:hover {
+        background: lighten($ui-base-color, 2%);
+      }
+    }
+
+    &__content {
+      padding-top: 12px;
+      padding-bottom: 16px;
+    }
+  }
+
+  .status__content {
+    padding-top: 0;
+
+    strong {
+      font-weight: 700;
+      background: linear-gradient(to right, orange , yellow, green, cyan, blue, violet,orange , yellow, green, cyan, blue, violet);
+      background-size: 200% 100%;
+      background-clip: text;
+      color: transparent;
+      animation: Swag 2s linear 0s infinite;
+    }
+  }
 }
diff --git a/app/javascript/flavours/glitch/util/compare_id.js b/app/javascript/flavours/glitch/util/compare_id.js
new file mode 100644
index 000000000..aaff66481
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/compare_id.js
@@ -0,0 +1,10 @@
+export default function compareId(id1, id2) {
+  if (id1 === id2) {
+    return 0;
+  }
+  if (id1.length === id2.length) {
+    return id1 > id2 ? 1 : -1;
+  } else {
+    return id1.length > id2.length ? 1 : -1;
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/html.js b/app/javascript/flavours/glitch/util/html.js
new file mode 100644
index 000000000..0b646ce58
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/html.js
@@ -0,0 +1,6 @@
+export const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  html = html.replace(/<br \/>|<br>|\n/g, ' ');
+  wrapper.innerHTML = html;
+  return wrapper.textContent;
+};
diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js
new file mode 100644
index 000000000..279a858ca
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/resize_image.js
@@ -0,0 +1,116 @@
+import EXIF from 'exif-js';
+
+const MAX_IMAGE_DIMENSION = 1280;
+
+const getImageUrl = inputFile => new Promise((resolve, reject) => {
+  if (window.URL && URL.createObjectURL) {
+    try {
+      resolve(URL.createObjectURL(inputFile));
+    } catch (error) {
+      reject(error);
+    }
+    return;
+  }
+
+  const reader = new FileReader();
+  reader.onerror = (...args) => reject(...args);
+  reader.onload  = ({ target }) => resolve(target.result);
+
+  reader.readAsDataURL(inputFile);
+});
+
+const loadImage = inputFile => new Promise((resolve, reject) => {
+  getImageUrl(inputFile).then(url => {
+    const img = new Image();
+
+    img.onerror = (...args) => reject(...args);
+    img.onload  = () => resolve(img);
+
+    img.src = url;
+  }).catch(reject);
+});
+
+const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
+  if (type !== 'image/jpeg') {
+    resolve(1);
+    return;
+  }
+
+  EXIF.getData(img, () => {
+    const orientation = EXIF.getTag(img, 'Orientation');
+    resolve(orientation);
+  });
+});
+
+const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => {
+  const canvas  = document.createElement('canvas');
+
+  if (4 < orientation && orientation < 9) {
+    canvas.width  = height;
+    canvas.height = width;
+  } else {
+    canvas.width  = width;
+    canvas.height = height;
+  }
+
+  const context = canvas.getContext('2d');
+
+  switch (orientation) {
+  case 2: context.transform(-1, 0, 0, 1, width, 0); break;
+  case 3: context.transform(-1, 0, 0, -1, width, height); break;
+  case 4: context.transform(1, 0, 0, -1, 0, height); break;
+  case 5: context.transform(0, 1, 1, 0, 0, 0); break;
+  case 6: context.transform(0, 1, -1, 0, height, 0); break;
+  case 7: context.transform(0, -1, -1, 0, height, width); break;
+  case 8: context.transform(0, -1, 1, 0, 0, width); break;
+  }
+
+  context.drawImage(img, 0, 0, width, height);
+
+  canvas.toBlob(resolve, type);
+});
+
+const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => {
+  const { width, height } = img;
+
+  let newWidth, newHeight;
+
+  if (width > height) {
+    newHeight = height * MAX_IMAGE_DIMENSION / width;
+    newWidth  = MAX_IMAGE_DIMENSION;
+  } else if (height > width) {
+    newWidth  = width * MAX_IMAGE_DIMENSION / height;
+    newHeight = MAX_IMAGE_DIMENSION;
+  } else {
+    newWidth  = MAX_IMAGE_DIMENSION;
+    newHeight = MAX_IMAGE_DIMENSION;
+  }
+
+  getOrientation(img, type)
+    .then(orientation => processImage(img, {
+      width: newWidth,
+      height: newHeight,
+      orientation,
+      type,
+    }))
+    .then(resolve)
+    .catch(reject);
+});
+
+export default inputFile => new Promise((resolve, reject) => {
+  if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
+    resolve(inputFile);
+    return;
+  }
+
+  loadImage(inputFile).then(img => {
+    if (img.width < MAX_IMAGE_DIMENSION && img.height < MAX_IMAGE_DIMENSION) {
+      resolve(inputFile);
+      return;
+    }
+
+    resizeImage(img, inputFile.type)
+      .then(resolve)
+      .catch(() => resolve(inputFile));
+  }).catch(reject);
+});
diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js
index 36c68ffc5..9928d0dd7 100644
--- a/app/javascript/flavours/glitch/util/stream.js
+++ b/app/javascript/flavours/glitch/util/stream.js
@@ -1,21 +1,24 @@
 import WebSocketClient from 'websocket.js';
 
-export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
+const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
+
+export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) {
   return (dispatch, getState) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
     const accessToken = getState().getIn(['meta', 'access_token']);
-    const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
+    const { onDisconnect, onReceive } = callbacks(dispatch, getState);
+
     let polling = null;
 
     const setupPolling = () => {
-      polling = setInterval(() => {
-        pollingRefresh(dispatch);
-      }, 20000);
+      pollingRefresh(dispatch, () => {
+        polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000));
+      });
     };
 
     const clearPolling = () => {
       if (polling) {
-        clearInterval(polling);
+        clearTimeout(polling);
         polling = null;
       }
     };
@@ -25,13 +28,13 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
         if (pollingRefresh) {
           clearPolling();
         }
-        onConnect();
       },
 
       disconnected () {
         if (pollingRefresh) {
-          setupPolling();
+          polling = setTimeout(() => setupPolling(), randomIntUpTo(40000));
         }
+
         onDisconnect();
       },
 
@@ -44,7 +47,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
           clearPolling();
           pollingRefresh(dispatch);
         }
-        onConnect();
       },
 
     });
@@ -53,6 +55,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
       if (subscription) {
         subscription.close();
       }
+
       clearPolling();
     };
 
@@ -62,7 +65,13 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
 
 
 export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
-  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+  const params = [ `stream=${stream}` ];
+
+  if (accessToken !== null) {
+    params.push(`access_token=${accessToken}`);
+  }
+
+  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`);
 
   ws.onopen      = connected;
   ws.onmessage   = e => received(JSON.parse(e.data));
diff --git a/app/javascript/mastodon/actions/columns.js b/app/javascript/mastodon/actions/columns.js
index bcb0cdf98..f550e6c48 100644
--- a/app/javascript/mastodon/actions/columns.js
+++ b/app/javascript/mastodon/actions/columns.js
@@ -1,8 +1,9 @@
 import { saveSettings } from './settings';
 
-export const COLUMN_ADD    = 'COLUMN_ADD';
-export const COLUMN_REMOVE = 'COLUMN_REMOVE';
-export const COLUMN_MOVE   = 'COLUMN_MOVE';
+export const COLUMN_ADD           = 'COLUMN_ADD';
+export const COLUMN_REMOVE        = 'COLUMN_REMOVE';
+export const COLUMN_MOVE          = 'COLUMN_MOVE';
+export const COLUMN_PARAMS_CHANGE = 'COLUMN_PARAMS_CHANGE';
 
 export function addColumn(id, params) {
   return dispatch => {
@@ -38,3 +39,15 @@ export function moveColumn(uuid, direction) {
     dispatch(saveSettings());
   };
 };
+
+export function changeColumnParams(uuid, params) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_PARAMS_CHANGE,
+      uuid,
+      params,
+    });
+
+    dispatch(saveSettings());
+  };
+}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 5f1274fab..c015d3a99 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -1,20 +1,29 @@
 import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../../features/emoji/emoji';
+import { unescapeHTML } from '../../utils/html';
 
 const domParser = new DOMParser();
 
+const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
+  obj[`:${emoji.shortcode}:`] = emoji;
+  return obj;
+}, {});
+
 export function normalizeAccount(account) {
   account = { ...account };
 
+  const emojiMap = makeEmojiMap(account);
   const displayName = account.display_name.length === 0 ? account.username : account.display_name;
-  account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
-  account.note_emojified = emojify(account.note);
+
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
+  account.note_emojified = emojify(account.note, emojiMap);
 
   if (account.fields) {
     account.fields = account.fields.map(pair => ({
       ...pair,
       name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
-      value_emojified: emojify(pair.value),
+      value_emojified: emojify(pair.value, emojiMap),
+      value_plain: unescapeHTML(pair.value),
     }));
   }
 
@@ -42,11 +51,7 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.hidden = normalOldStatus.get('hidden');
   } else {
     const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
-
-    const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
-      obj[`:${emoji.shortcode}:`] = emoji;
-      return obj;
-    }, {});
+    const emojiMap = makeEmojiMap(normalStatus);
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 7aa070f56..3f95f6667 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -8,6 +8,7 @@ import {
   importFetchedStatuses,
 } from './importer';
 import { defineMessages } from 'react-intl';
+import { unescapeHTML } from '../utils/html';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -21,6 +22,7 @@ export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
 
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+  group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
 });
 
 const fetchRelatedRelationships = (dispatch, notifications) => {
@@ -31,13 +33,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
   }
 };
 
-const unescapeHTML = (html) => {
-  const wrapper = document.createElement('div');
-  html = html.replace(/<br \/>|<br>|\n/g, ' ');
-  wrapper.innerHTML = html;
-  return wrapper.textContent;
-};
-
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
     const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
@@ -82,9 +77,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
 
 const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
 
-export function expandNotifications({ maxId } = {}) {
+const noOp = () => {};
+
+export function expandNotifications({ maxId } = {}, done = noOp) {
   return (dispatch, getState) => {
-    if (getState().getIn(['notifications', 'isLoading'])) {
+    const notifications = getState().get('notifications');
+
+    if (notifications.get('isLoading')) {
+      done();
       return;
     }
 
@@ -93,6 +93,10 @@ export function expandNotifications({ maxId } = {}) {
       exclude_types: excludeTypesFromSettings(getState()),
     };
 
+    if (!maxId && notifications.get('items').size > 0) {
+      params.since_id = notifications.getIn(['items', 0]);
+    }
+
     dispatch(expandNotificationsRequest());
 
     api(getState).get('/api/v1/notifications', { params }).then(response => {
@@ -103,8 +107,10 @@ export function expandNotifications({ maxId } = {}) {
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
       fetchRelatedRelationships(dispatch, response.data);
+      done();
     }).catch(error => {
       dispatch(expandNotificationsFail(error));
+      done();
     });
   };
 };
diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js
index 82fe4519a..b0f42b6a2 100644
--- a/app/javascript/mastodon/actions/push_notifications/registerer.js
+++ b/app/javascript/mastodon/actions/push_notifications/registerer.js
@@ -51,13 +51,6 @@ export function register () {
   return (dispatch, getState) => {
     dispatch(setBrowserSupport(supportsPushNotifications));
 
-    if (me && !pushNotificationsSetting.get(me)) {
-      const alerts = getState().getIn(['push_notifications', 'alerts']);
-      if (alerts) {
-        pushNotificationsSetting.set(me, { alerts: alerts });
-      }
-    }
-
     if (supportsPushNotifications) {
       if (!getApplicationServerKey()) {
         console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 882c1709e..b670d25c3 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -33,7 +33,7 @@ export function submitSearch() {
 
     dispatch(fetchSearchRequest());
 
-    api(getState).get('/api/v1/search', {
+    api(getState).get('/api/v2/search', {
       params: {
         q: value,
         resolve: true,
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index 14215ab6d..f56853bff 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -36,15 +36,13 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
   });
 }
 
-function refreshHomeTimelineAndNotification (dispatch) {
-  dispatch(expandHomeTimeline());
-  dispatch(expandNotifications());
-}
+const refreshHomeTimelineAndNotification = (dispatch, done) => {
+  dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+};
 
-export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
-export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
-export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
-export const connectPublicStream = () => connectTimelineStream('public', 'public');
-export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
-export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
-export const connectListStream = (id) => connectTimelineStream(`list:${id}`, `list&list=${id}`);
+export const connectUserStream      = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
+export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
+export const connectPublicStream    = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`);
+export const connectHashtagStream   = tag => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
+export const connectDirectStream    = () => connectTimelineStream('direct', 'direct');
+export const connectListStream      = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index eca847ee7..11a199db6 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,6 +1,6 @@
 import { importFetchedStatus, importFetchedStatuses } from './importer';
 import api, { getLinks } from '../api';
-import { Map as ImmutableMap } from 'immutable';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
 export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
@@ -13,21 +13,9 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
-export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
-
 export function updateTimeline(timeline, status) {
   return (dispatch, getState) => {
     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
-    const parents = [];
-
-    if (status.in_reply_to_id) {
-      let parent = getState().getIn(['statuses', status.in_reply_to_id]);
-
-      while (parent && parent.get('in_reply_to_id')) {
-        parents.push(parent.get('id'));
-        parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
-      }
-    }
 
     dispatch(importFetchedStatus(status));
 
@@ -37,14 +25,6 @@ export function updateTimeline(timeline, status) {
       status,
       references,
     });
-
-    if (parents.length > 0) {
-      dispatch({
-        type: TIMELINE_CONTEXT_UPDATE,
-        status,
-        references: parents,
-      });
-    }
   };
 };
 
@@ -64,35 +44,44 @@ export function deleteFromTimelines(id) {
   };
 };
 
-export function expandTimeline(timelineId, path, params = {}) {
+const noOp = () => {};
+
+export function expandTimeline(timelineId, path, params = {}, done = noOp) {
   return (dispatch, getState) => {
     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
 
     if (timeline.get('isLoading')) {
+      done();
       return;
     }
 
+    if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
+      params.since_id = timeline.getIn(['items', 0]);
+    }
+
     dispatch(expandTimelineRequest(timelineId));
 
     api(getState).get(path, { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
       dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206));
+      done();
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error));
+      done();
     });
   };
 };
 
-export const expandHomeTimeline         = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId });
-export const expandPublicTimeline       = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId });
-export const expandCommunityTimeline    = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId });
-export const expandDirectTimeline       = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId });
-export const expandAccountTimeline      = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
+export const expandHomeTimeline            = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
+export const expandPublicTimeline          = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
+export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
+export const expandDirectTimeline          = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
+export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
-export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
-export const expandHashtagTimeline      = (hashtag, { maxId } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId });
-export const expandListTimeline         = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId });
+export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
+export const expandHashtagTimeline         = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done);
+export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 
 export function expandTimelineRequest(timeline) {
   return {
diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js
new file mode 100644
index 000000000..853e4f60a
--- /dev/null
+++ b/app/javascript/mastodon/actions/trends.js
@@ -0,0 +1,32 @@
+import api from '../api';
+
+export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
+export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
+export const TRENDS_FETCH_FAIL    = 'TRENDS_FETCH_FAIL';
+
+export const fetchTrends = () => (dispatch, getState) => {
+  dispatch(fetchTrendsRequest());
+
+  api(getState)
+    .get('/api/v1/trends')
+    .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
+    .catch(err => dispatch(fetchTrendsFail(err)));
+};
+
+export const fetchTrendsRequest = () => ({
+  type: TRENDS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendsSuccess = trends => ({
+  type: TRENDS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendsFail = error => ({
+  type: TRENDS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 982d34718..0a6e7c627 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -43,6 +43,7 @@ class DropdownMenu extends React.PureComponent {
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+    if (this.focusedItem) this.focusedItem.focus();
     this.setState({ mounted: true });
   }
 
@@ -55,6 +56,46 @@ class DropdownMenu extends React.PureComponent {
     this.node = c;
   }
 
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
+  handleKeyDown = e => {
+    const items = Array.from(this.node.getElementsByTagName('a'));
+    const index = items.indexOf(e.currentTarget);
+    let element;
+
+    switch(e.key) {
+    case 'Enter':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = items[index+1];
+      if (element) {
+        element.focus();
+      }
+      break;
+    case 'ArrowUp':
+      element = items[index-1];
+      if (element) {
+        element.focus();
+      }
+      break;
+    case 'Home':
+      element = items[0];
+      if (element) {
+        element.focus();
+      }
+      break;
+    case 'End':
+      element = items[items.length-1];
+      if (element) {
+        element.focus();
+      }
+      break;
+    }
+  }
+
   handleClick = e => {
     const i = Number(e.currentTarget.getAttribute('data-index'));
     const { action, to } = this.props.items[i];
@@ -79,7 +120,7 @@ class DropdownMenu extends React.PureComponent {
 
     return (
       <li className='dropdown-menu__item' key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
+        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleKeyDown} data-index={i}>
           {text}
         </a>
       </li>
@@ -156,9 +197,6 @@ export default class Dropdown extends React.PureComponent {
 
   handleKeyDown = e => {
     switch(e.key) {
-    case 'Enter':
-      this.handleClick(e);
-      break;
     case 'Escape':
       this.handleClose();
       break;
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 13e1fcc52..7c4444e0e 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -20,6 +20,7 @@ class Item extends React.PureComponent {
     index: PropTypes.number.isRequired,
     size: PropTypes.number.isRequired,
     onClick: PropTypes.func.isRequired,
+    displayWidth: PropTypes.number,
   };
 
   static defaultProps = {
@@ -58,7 +59,7 @@ class Item extends React.PureComponent {
   }
 
   render () {
-    const { attachment, index, size, standalone } = this.props;
+    const { attachment, index, size, standalone, displayWidth } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -121,7 +122,7 @@ class Item extends React.PureComponent {
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
       const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
-      const sizes  = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+      const sizes  = hasSize ? `${displayWidth * (width / 100)}px` : null;
 
       const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
       const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
@@ -263,9 +264,9 @@ export default class MediaGallery extends React.PureComponent {
       const size = media.take(4).size;
 
       if (this.isStandaloneEligible()) {
-        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
+        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
       } else {
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
+        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
       }
     }
 
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index 7cdd63910..4b433f32c 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -25,6 +25,7 @@ export default class ScrollableList extends PureComponent {
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
     emptyMessage: PropTypes.node,
     children: PropTypes.node,
   };
@@ -35,7 +36,6 @@ export default class ScrollableList extends PureComponent {
 
   state = {
     fullscreen: null,
-    mouseOver: false,
   };
 
   intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -72,7 +72,7 @@ export default class ScrollableList extends PureComponent {
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
       this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
-    if (someItemInserted && this.node.scrollTop > 0 || (this.state.mouseOver && !prevProps.isLoading)) {
+    if (someItemInserted && this.node.scrollTop > 0) {
       return this.node.scrollHeight - this.node.scrollTop;
     } else {
       return null;
@@ -140,16 +140,8 @@ export default class ScrollableList extends PureComponent {
     this.props.onLoadMore();
   }
 
-  handleMouseEnter = () => {
-    this.setState({ mouseOver: true });
-  }
-
-  handleMouseLeave = () => {
-    this.setState({ mouseOver: false });
-  }
-
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -158,7 +150,7 @@ export default class ScrollableList extends PureComponent {
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
       scrollableArea = (
-        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
           <div role='feed' className='item-list'>
             {prepend}
 
@@ -181,8 +173,12 @@ export default class ScrollableList extends PureComponent {
       );
     } else {
       scrollableArea = (
-        <div className='empty-column-indicator' ref={this.setRef}>
-          {emptyMessage}
+        <div style={{ flex: '1 1 auto', display: 'flex', flexDirection: 'column' }}>
+          {alwaysPrepend && prepend}
+
+          <div className='empty-column-indicator' ref={this.setRef}>
+            {emptyMessage}
+          </div>
         </div>
       );
     }
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 402d558c4..fd08ff3b7 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -84,8 +84,8 @@ export default class Status extends ImmutablePureComponent {
     return <div className='media-spoiler-video' style={{ height: '110px' }} />;
   }
 
-  handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
+  handleOpenVideo = (media, startTime) => {
+    this.props.onOpenVideo(media, startTime);
   }
 
   handleHotkeyReply = e => {
@@ -206,7 +206,7 @@ export default class Status extends ImmutablePureComponent {
         );
       } else {
         media = (
-          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
+          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
             {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
           </Bundle>
         );
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 0c971ceb0..1c34d0640 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
     emptyMessage: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
   };
 
   static defaultProps = {
diff --git a/app/javascript/mastodon/containers/card_container.js b/app/javascript/mastodon/containers/card_container.js
deleted file mode 100644
index 11b9f88d4..000000000
--- a/app/javascript/mastodon/containers/card_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Card from '../features/status/components/card';
-import { fromJS } from 'immutable';
-
-export default class CardContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string,
-    card: PropTypes.array.isRequired,
-  };
-
-  render () {
-    const { card, ...props } = this.props;
-    return <Card card={fromJS(card)} {...props} />;
-  }
-
-}
diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js
new file mode 100644
index 000000000..1700fba05
--- /dev/null
+++ b/app/javascript/mastodon/containers/media_container.js
@@ -0,0 +1,90 @@
+import React, { PureComponent, Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import MediaGallery from '../components/media_gallery';
+import Video from '../features/video';
+import Card from '../features/status/components/card';
+import ModalRoot from '../components/modal_root';
+import MediaModal from '../features/ui/components/media_modal';
+import { List as ImmutableList, fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card };
+
+export default class MediaContainer extends PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    components: PropTypes.object.isRequired,
+  };
+
+  state = {
+    media: null,
+    index: null,
+    time: null,
+  };
+
+  handleOpenMedia = (media, index) => {
+    document.body.classList.add('media-standalone__body');
+    this.setState({ media, index });
+  }
+
+  handleOpenVideo = (video, time) => {
+    const media = ImmutableList([video]);
+
+    document.body.classList.add('media-standalone__body');
+    this.setState({ media, time });
+  }
+
+  handleCloseMedia = () => {
+    document.body.classList.remove('media-standalone__body');
+    this.setState({ media: null, index: null, time: null });
+  }
+
+  render () {
+    const { locale, components } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Fragment>
+          {[].map.call(components, (component, i) => {
+            const componentName = component.getAttribute('data-component');
+            const Component = MEDIA_COMPONENTS[componentName];
+            const { media, card, ...props } = JSON.parse(component.getAttribute('data-props'));
+
+            Object.assign(props, {
+              ...(media ? { media: fromJS(media) } : {}),
+              ...(card  ? { card:  fromJS(card)  } : {}),
+
+              ...(componentName === 'Video' ? {
+                onOpenVideo: this.handleOpenVideo,
+              } : {
+                onOpenMedia: this.handleOpenMedia,
+              }),
+            });
+
+            return ReactDOM.createPortal(
+              <Component {...props} key={`media-${i}`} />,
+              component,
+            );
+          })}
+          <ModalRoot onClose={this.handleCloseMedia}>
+            {this.state.media && (
+              <MediaModal
+                media={this.state.media}
+                index={this.state.index || 0}
+                time={this.state.time}
+                onClose={this.handleCloseMedia}
+              />
+            )}
+          </ModalRoot>
+        </Fragment>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/containers/media_galleries_container.js b/app/javascript/mastodon/containers/media_galleries_container.js
deleted file mode 100644
index d77bd688b..000000000
--- a/app/javascript/mastodon/containers/media_galleries_container.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from '../locales';
-import MediaGallery from '../components/media_gallery';
-import ModalRoot from '../components/modal_root';
-import MediaModal from '../features/ui/components/media_modal';
-import { fromJS } from 'immutable';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class MediaGalleriesContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-    galleries: PropTypes.object.isRequired,
-  };
-
-  state = {
-    media: null,
-    index: null,
-  };
-
-  handleOpenMedia = (media, index) => {
-    document.body.classList.add('media-gallery-standalone__body');
-    this.setState({ media, index });
-  }
-
-  handleCloseMedia = () => {
-    document.body.classList.remove('media-gallery-standalone__body');
-    this.setState({ media: null, index: null });
-  }
-
-  render () {
-    const { locale, galleries } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <React.Fragment>
-          {[].map.call(galleries, gallery => {
-            const { media, ...props } = JSON.parse(gallery.getAttribute('data-props'));
-
-            return ReactDOM.createPortal(
-              <MediaGallery
-                {...props}
-                media={fromJS(media)}
-                onOpenMedia={this.handleOpenMedia}
-              />,
-              gallery
-            );
-          })}
-          <ModalRoot onClose={this.handleCloseMedia}>
-            {this.state.media === null || this.state.index === null ? null : (
-              <MediaModal
-                media={this.state.media}
-                index={this.state.index}
-                onClose={this.handleCloseMedia}
-              />
-            )}
-          </ModalRoot>
-        </React.Fragment>
-      </IntlProvider>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
index 8719bb5c9..a1a4bd024 100644
--- a/app/javascript/mastodon/containers/timeline_container.js
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -1,4 +1,5 @@
-import React from 'react';
+import React, { Fragment } from 'react';
+import ReactDOM from 'react-dom';
 import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
 import configureStore from '../store/configureStore';
@@ -8,6 +9,7 @@ import { getLocale } from '../locales';
 import PublicTimeline from '../features/standalone/public_timeline';
 import CommunityTimeline from '../features/standalone/community_timeline';
 import HashtagTimeline from '../features/standalone/hashtag_timeline';
+import ModalContainer from '../features/ui/containers/modal_container';
 import initialState from '../initial_state';
 
 const { localeData, messages } = getLocale();
@@ -47,7 +49,13 @@ export default class TimelineContainer extends React.PureComponent {
     return (
       <IntlProvider locale={locale} messages={messages}>
         <Provider store={store}>
-          {timeline}
+          <Fragment>
+            {timeline}
+            {ReactDOM.createPortal(
+              <ModalContainer />,
+              document.getElementById('modal-container'),
+            )}
+          </Fragment>
         </Provider>
       </IntlProvider>
     );
diff --git a/app/javascript/mastodon/containers/video_container.js b/app/javascript/mastodon/containers/video_container.js
deleted file mode 100644
index 2fd353096..000000000
--- a/app/javascript/mastodon/containers/video_container.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from '../locales';
-import Video from '../features/video';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class VideoContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-  };
-
-  render () {
-    const { locale, ...props } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <Video {...props} />
-      </IntlProvider>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index bbf886dca..7358053da 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -131,6 +131,7 @@ export default class Header extends ImmutablePureComponent {
     const content         = { __html: account.get('note_emojified') };
     const displayNameHtml = { __html: account.get('display_name_html') };
     const fields          = account.get('fields');
+    const badge           = account.get('bot') ? (<div className='roles'><div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div></div>) : null;
 
     return (
       <div className={classNames('account__header', { inactive: !!account.get('moved') })} style={{ backgroundImage: `url(${account.get('header')})` }}>
@@ -139,19 +140,20 @@ export default class Header extends ImmutablePureComponent {
 
           <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
+
+          {badge}
+
           <div className='account__header__content' dangerouslySetInnerHTML={content} />
 
           {fields.size > 0 && (
-            <table className='account__header__fields'>
-              <tbody>
-                {fields.map((pair, i) => (
-                  <tr key={i}>
-                    <th dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} />
-                    <td dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                  </tr>
-                ))}
-              </tbody>
-            </table>
+            <div className='account__header__fields'>
+              {fields.map((pair, i) => (
+                <dl key={i}>
+                  <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+                  <dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value_plain')} />
+                </dl>
+              ))}
+            </div>
           )}
 
           {info}
diff --git a/app/javascript/mastodon/features/community_timeline/components/section_headline.js b/app/javascript/mastodon/features/community_timeline/components/section_headline.js
new file mode 100644
index 000000000..c7176d04b
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/components/section_headline.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { NavLink } from 'react-router-dom';
+
+export default class SectionHeadline extends Component {
+
+  static propTypes = {
+    timelineId: PropTypes.string.isRequired,
+    to: PropTypes.string.isRequired,
+    pinned: PropTypes.bool.isRequired,
+    onlyMedia: PropTypes.bool.isRequired,
+    onClick: PropTypes.func,
+  };
+
+  shouldComponentUpdate (nextProps) {
+    return (
+      this.props.onlyMedia !== nextProps.onlyMedia ||
+      this.props.pinned !== nextProps.pinned ||
+      this.props.to !== nextProps.to ||
+      this.props.timelineId !== nextProps.timelineId
+    );
+  }
+
+  handleClick = e => {
+    const { onClick } = this.props;
+
+    if (typeof onClick === 'function') {
+      e.preventDefault();
+
+      onClick.call(this, e);
+    }
+  }
+
+  render () {
+    const { timelineId, to, pinned, onlyMedia } = this.props;
+
+    return (
+      <div className={`${timelineId}-timeline__section-headline`}>
+        {pinned ? (
+          <Fragment>
+            <a href={to} className={!onlyMedia ? 'active' : undefined} onClick={this.handleClick}>
+              <FormattedMessage id='timeline.posts' defaultMessage='Toots' />
+            </a>
+            <a href={`${to}/media`} className={onlyMedia ? 'active' : undefined} onClick={this.handleClick}>
+              <FormattedMessage id='timeline.media' defaultMessage='Media' />
+            </a>
+          </Fragment>
+        ) : (
+          <Fragment>
+            <NavLink exact to={to} replace><FormattedMessage id='timeline.posts' defaultMessage='Toots' /></NavLink>
+            <NavLink exact to={`${to}/media`} replace><FormattedMessage id='timeline.media' defaultMessage='Media' /></NavLink>
+          </Fragment>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index 870474ed5..d375edbd5 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -1,42 +1,48 @@
 import React from 'react';
 import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import PropTypes from 'prop-types';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../../components/column';
 import ColumnHeader from '../../components/column_header';
 import { expandCommunityTimeline } from '../../actions/timelines';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from '../../actions/columns';
 import ColumnSettingsContainer from './containers/column_settings_container';
+import SectionHeadline from './components/section_headline';
 import { connectCommunityStream } from '../../actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'column.community', defaultMessage: 'Local timeline' },
 });
 
-const mapStateToProps = state => ({
-  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+const mapStateToProps = (state, { onlyMedia }) => ({
+  hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
 });
 
 @connect(mapStateToProps)
 @injectIntl
 export default class CommunityTimeline extends React.PureComponent {
 
+  static defaultProps = {
+    onlyMedia: false,
+  };
+
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
+    onlyMedia: PropTypes.bool,
   };
 
   handlePin = () => {
-    const { columnId, dispatch } = this.props;
+    const { columnId, dispatch, onlyMedia } = this.props;
 
     if (columnId) {
       dispatch(removeColumn(columnId));
     } else {
-      dispatch(addColumn('COMMUNITY', {}));
+      dispatch(addColumn('COMMUNITY', { other: { onlyMedia } }));
     }
   }
 
@@ -50,10 +56,20 @@ export default class CommunityTimeline extends React.PureComponent {
   }
 
   componentDidMount () {
-    const { dispatch } = this.props;
+    const { dispatch, onlyMedia } = this.props;
 
-    dispatch(expandCommunityTimeline());
-    this.disconnect = dispatch(connectCommunityStream());
+    dispatch(expandCommunityTimeline({ onlyMedia }));
+    this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.onlyMedia !== this.props.onlyMedia) {
+      const { dispatch, onlyMedia } = this.props;
+
+      this.disconnect();
+      dispatch(expandCommunityTimeline({ onlyMedia }));
+      this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+    }
   }
 
   componentWillUnmount () {
@@ -68,13 +84,32 @@ export default class CommunityTimeline extends React.PureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandCommunityTimeline({ maxId }));
+    const { dispatch, onlyMedia } = this.props;
+
+    dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
+  }
+
+  handleHeadlineLinkClick = e => {
+    const { columnId, dispatch } = this.props;
+    const onlyMedia = /\/media$/.test(e.currentTarget.href);
+
+    dispatch(changeColumnParams(columnId, { other: { onlyMedia } }));
   }
 
   render () {
-    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
     const pinned = !!columnId;
 
+    const headline = (
+      <SectionHeadline
+        timelineId='community'
+        to='/timelines/public/local'
+        pinned={pinned}
+        onlyMedia={onlyMedia}
+        onClick={this.handleHeadlineLinkClick}
+      />
+    );
+
     return (
       <Column ref={this.setRef}>
         <ColumnHeader
@@ -91,9 +126,11 @@ export default class CommunityTimeline extends React.PureComponent {
         </ColumnHeader>
 
         <StatusListContainer
+          prepend={headline}
+          alwaysPrepend
           trackScroll={!pinned}
           scrollKey={`community_timeline-${columnId}`}
-          timelineId='community'
+          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!' />}
         />
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 39eb02362..6cc594c88 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -40,6 +40,7 @@ export default class ComposeForm extends ImmutablePureComponent {
     privacy: PropTypes.string,
     spoiler_text: PropTypes.string,
     focusDate: PropTypes.instanceOf(Date),
+    caretPosition: PropTypes.number,
     preselectDate: PropTypes.instanceOf(Date),
     is_submitting: PropTypes.bool,
     is_uploading: PropTypes.bool,
@@ -96,7 +97,6 @@ export default class ComposeForm extends ImmutablePureComponent {
   }
 
   onSuggestionSelected = (tokenStart, token, value) => {
-    this._restoreCaret = null;
     this.props.onSuggestionSelected(tokenStart, token, value);
   }
 
@@ -104,31 +104,21 @@ export default class ComposeForm extends ImmutablePureComponent {
     this.props.onChangeSpoilerText(e.target.value);
   }
 
-  componentWillReceiveProps (nextProps) {
-    // If this is the update where we've finished uploading,
-    // save the last caret position so we can restore it below!
-    if (!nextProps.is_uploading && this.props.is_uploading) {
-      this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
-    }
-  }
-
   componentDidUpdate (prevProps) {
     // This statement does several things:
     // - If we're beginning a reply, and,
     //     - Replying to zero or one users, places the cursor at the end of the textbox.
     //     - Replying to more than one user, selects any usernames past the first;
     //       this provides a convenient shortcut to drop everyone else from the conversation.
-    // - If we've just finished uploading an image, and have a saved caret position,
-    //   restores the cursor to that position after the text changes!
-    if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
+    if (this.props.focusDate !== prevProps.focusDate) {
       let selectionEnd, selectionStart;
 
       if (this.props.preselectDate !== prevProps.preselectDate) {
         selectionEnd   = this.props.text.length;
         selectionStart = this.props.text.search(/\s/) + 1;
-      } else if (typeof this._restoreCaret === 'number') {
-        selectionStart = this._restoreCaret;
-        selectionEnd   = this._restoreCaret;
+      } else if (typeof this.props.caretPosition === 'number') {
+        selectionStart = this.props.caretPosition;
+        selectionEnd   = this.props.caretPosition;
       } else {
         selectionEnd   = this.props.text.length;
         selectionStart = selectionEnd;
@@ -148,10 +138,8 @@ export default class ComposeForm extends ImmutablePureComponent {
   handleEmojiPick = (data) => {
     const { text }     = this.props;
     const position     = this.autosuggestTextarea.textarea.selectionStart;
-    const emojiChar    = data.native;
     const needsSpace   = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
 
-    this._restoreCaret = position + emojiChar.length + 1 + (needsSpace ? 1 : 0);
     this.props.onPickEmoji(position, data, needsSpace);
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 6b22ba84a..a772c1c95 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -42,22 +42,65 @@ class PrivacyDropdownMenu extends React.PureComponent {
     }
   }
 
-  handleClick = e => {
-    if (e.key === 'Escape') {
-      this.props.onClose();
-    } else if (!e.key || e.key === 'Enter') {
-      const value = e.currentTarget.getAttribute('data-index');
-
-      e.preventDefault();
+  handleKeyDown = e => {
+    const { items } = this.props;
+    const value = e.currentTarget.getAttribute('data-index');
+    const index = items.findIndex(item => {
+      return (item.value === value);
+    });
+    let element;
 
+    switch(e.key) {
+    case 'Escape':
       this.props.onClose();
-      this.props.onChange(value);
+      break;
+    case 'Enter':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1];
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1];
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
     }
   }
 
+  handleClick = e => {
+    const value = e.currentTarget.getAttribute('data-index');
+
+    e.preventDefault();
+
+    this.props.onClose();
+    this.props.onChange(value);
+  }
+
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+    if (this.focusedItem) this.focusedItem.focus();
     this.setState({ mounted: true });
   }
 
@@ -70,6 +113,10 @@ class PrivacyDropdownMenu extends React.PureComponent {
     this.node = c;
   }
 
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
   render () {
     const { mounted } = this.state;
     const { style, items, value } = this.props;
@@ -80,9 +127,9 @@ class PrivacyDropdownMenu extends React.PureComponent {
           // It should not be transformed when mounting because the resulting
           // size will be used to determine the coordinate of the menu by
           // react-overlays
-          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
+          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
             {items.map(item => (
-              <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
+              <div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
                 <div className='privacy-dropdown__option__icon'>
                   <i className={`fa fa-fw fa-${item.icon}`} />
                 </div>
@@ -147,9 +194,6 @@ export default class PrivacyDropdown extends React.PureComponent {
 
   handleKeyDown = e => {
     switch(e.key) {
-    case 'Enter':
-      this.handleToggle(e);
-      break;
     case 'Escape':
       this.handleClose();
       break;
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index 84455563c..445bf27bb 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -1,28 +1,82 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, FormattedNumber } from 'react-intl';
 import AccountContainer from '../../../containers/account_container';
 import StatusContainer from '../../../containers/status_container';
 import { Link } from 'react-router-dom';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+
+const shortNumberFormat = number => {
+  if (number < 1000) {
+    return <FormattedNumber value={number} />;
+  } else {
+    return <React.Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</React.Fragment>;
+  }
+};
+
+const renderHashtag = hashtag => (
+  <div className='trends__item' key={hashtag.get('name')}>
+    <div className='trends__item__name'>
+      <Link to={`/timelines/tag/${hashtag.get('name')}`}>
+        #<span>{hashtag.get('name')}</span>
+      </Link>
+
+      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
+    </div>
+
+    <div className='trends__item__current'>
+      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
+    </div>
+
+    <div className='trends__item__sparkline'>
+      <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
+        <SparklinesCurve style={{ fill: 'none' }} />
+      </Sparklines>
+    </div>
+  </div>
+);
 
 export default class SearchResults extends ImmutablePureComponent {
 
   static propTypes = {
     results: ImmutablePropTypes.map.isRequired,
+    trends: ImmutablePropTypes.list,
+    fetchTrends: PropTypes.func.isRequired,
   };
 
+  componentDidMount () {
+    const { fetchTrends } = this.props;
+    fetchTrends();
+  }
+
   render () {
-    const { results } = this.props;
+    const { results, trends } = this.props;
 
     let accounts, statuses, hashtags;
     let count = 0;
 
+    if (results.isEmpty()) {
+      return (
+        <div className='search-results'>
+          <div className='trends'>
+            <div className='trends__header'>
+              <i className='fa fa-fire fa-fw' />
+              <FormattedMessage id='trends.header' defaultMessage='Trending now' />
+            </div>
+
+            {trends && trends.map(hashtag => renderHashtag(hashtag))}
+          </div>
+        </div>
+      );
+    }
+
     if (results.get('accounts') && results.get('accounts').size > 0) {
       count   += results.get('accounts').size;
       accounts = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+          <h5><i className='fa fa-fw fa-users' /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
 
           {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
         </div>
@@ -33,7 +87,7 @@ export default class SearchResults extends ImmutablePureComponent {
       count   += results.get('statuses').size;
       statuses = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+          <h5><i className='fa fa-fw fa-quote-right' /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
         </div>
@@ -44,13 +98,9 @@ export default class SearchResults extends ImmutablePureComponent {
       count += results.get('hashtags').size;
       hashtags = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+          <h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
 
-          {results.get('hashtags').map(hashtag => (
-            <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
-              #{hashtag}
-            </Link>
-          ))}
+          {results.get('hashtags').map(hashtag => renderHashtag(hashtag))}
         </div>
       );
     }
@@ -58,6 +108,7 @@ export default class SearchResults extends ImmutablePureComponent {
     return (
       <div className='search-results'>
         <div className='search-results__header'>
+          <i className='fa fa-search fa-fw' />
           <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
         </div>
 
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index 61b2d19e0..bfa2b4727 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -77,7 +77,7 @@ export default class Upload extends ImmutablePureComponent {
           {({ scale }) => (
             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
               <div className={classNames('compose-form__upload__actions', { active })}>
-                <button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
+                <button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
                 {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
               </div>
 
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 c3aa580ee..3822dd711 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -19,6 +19,7 @@ const mapStateToProps = state => ({
   spoiler_text: state.getIn(['compose', 'spoiler_text']),
   privacy: state.getIn(['compose', 'privacy']),
   focusDate: state.getIn(['compose', 'focusDate']),
+  caretPosition: state.getIn(['compose', 'caretPosition']),
   preselectDate: state.getIn(['compose', 'preselectDate']),
   is_submitting: state.getIn(['compose', 'is_submitting']),
   is_uploading: state.getIn(['compose', 'is_uploading']),
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
index 16d95d417..7273460e2 100644
--- a/app/javascript/mastodon/features/compose/containers/search_results_container.js
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -1,8 +1,14 @@
 import { connect } from 'react-redux';
 import SearchResults from '../components/search_results';
+import { fetchTrends } from '../../../actions/trends';
 
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
+  trends: state.get('trends'),
 });
 
-export default connect(mapStateToProps)(SearchResults);
+const mapDispatchToProps = dispatch => ({
+  fetchTrends: () => dispatch(fetchTrends()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
index efaa02e9e..8200a319f 100644
--- a/app/javascript/mastodon/features/compose/containers/warning_container.js
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -17,11 +17,19 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
   if (needsLockWarning) {
     return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
   }
+
   if (hashtagWarning) {
     return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
   }
+
   if (directMessageWarning) {
-    return <Warning message={<FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be visible to all the mentioned users.' />} />;
+    const message = (
+      <span>
+        <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
+      </span>
+    );
+
+    return <Warning message={message} />;
   }
 
   return null;
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 67f0e7981..d8e9ad9ee 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -43,11 +43,19 @@ export default class Compose extends React.PureComponent {
   };
 
   componentDidMount () {
-    this.props.dispatch(mountCompose());
+    const { isSearchPage } = this.props;
+
+    if (!isSearchPage) {
+      this.props.dispatch(mountCompose());
+    }
   }
 
   componentWillUnmount () {
-    this.props.dispatch(unmountCompose());
+    const { isSearchPage } = this.props;
+
+    if (!isSearchPage) {
+      this.props.dispatch(unmountCompose());
+    }
   }
 
   onFocus = () => {
@@ -93,7 +101,7 @@ export default class Compose extends React.PureComponent {
         {(multiColumn || isSearchPage) && <SearchContainer /> }
 
         <div className='drawer__pager'>
-          <div className='drawer__inner' onFocus={this.onFocus}>
+          {!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
             <NavigationContainer onClose={this.onBlur} />
             <ComposeFormContainer />
             {multiColumn && (
@@ -101,7 +109,7 @@ export default class Compose extends React.PureComponent {
                 <img alt='' draggable='false' src={elephantUIPlane} />
               </div>
             )}
-          </div>
+          </div>}
 
           <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
             {({ x }) => (
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index 5a88f7601..341af582a 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -1,42 +1,48 @@
 import React from 'react';
 import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import PropTypes from 'prop-types';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../../components/column';
 import ColumnHeader from '../../components/column_header';
 import { expandPublicTimeline } from '../../actions/timelines';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from '../../actions/columns';
 import ColumnSettingsContainer from './containers/column_settings_container';
+import SectionHeadline from '../community_timeline/components/section_headline';
 import { connectPublicStream } from '../../actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'column.public', defaultMessage: 'Federated timeline' },
 });
 
-const mapStateToProps = state => ({
-  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
+const mapStateToProps = (state, { onlyMedia }) => ({
+  hasUnread: state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
 });
 
 @connect(mapStateToProps)
 @injectIntl
 export default class PublicTimeline extends React.PureComponent {
 
+  static defaultProps = {
+    onlyMedia: false,
+  };
+
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     multiColumn: PropTypes.bool,
     hasUnread: PropTypes.bool,
+    onlyMedia: PropTypes.bool,
   };
 
   handlePin = () => {
-    const { columnId, dispatch } = this.props;
+    const { columnId, dispatch, onlyMedia } = this.props;
 
     if (columnId) {
       dispatch(removeColumn(columnId));
     } else {
-      dispatch(addColumn('PUBLIC', {}));
+      dispatch(addColumn('PUBLIC', { other: { onlyMedia } }));
     }
   }
 
@@ -50,10 +56,20 @@ export default class PublicTimeline extends React.PureComponent {
   }
 
   componentDidMount () {
-    const { dispatch } = this.props;
+    const { dispatch, onlyMedia } = this.props;
 
-    dispatch(expandPublicTimeline());
-    this.disconnect = dispatch(connectPublicStream());
+    dispatch(expandPublicTimeline({ onlyMedia }));
+    this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.onlyMedia !== this.props.onlyMedia) {
+      const { dispatch, onlyMedia } = this.props;
+
+      this.disconnect();
+      dispatch(expandPublicTimeline({ onlyMedia }));
+      this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
+    }
   }
 
   componentWillUnmount () {
@@ -68,13 +84,32 @@ export default class PublicTimeline extends React.PureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandPublicTimeline({ maxId }));
+    const { dispatch, onlyMedia } = this.props;
+
+    dispatch(expandPublicTimeline({ maxId, onlyMedia }));
+  }
+
+  handleHeadlineLinkClick = e => {
+    const { columnId, dispatch } = this.props;
+    const onlyMedia = /\/media$/.test(e.currentTarget.href);
+
+    dispatch(changeColumnParams(columnId, { other: { onlyMedia } }));
   }
 
   render () {
-    const { intl, columnId, hasUnread, multiColumn } = this.props;
+    const { intl, columnId, hasUnread, multiColumn, onlyMedia } = this.props;
     const pinned = !!columnId;
 
+    const headline = (
+      <SectionHeadline
+        timelineId='public'
+        to='/timelines/public'
+        pinned={pinned}
+        onlyMedia={onlyMedia}
+        onClick={this.handleHeadlineLinkClick}
+      />
+    );
+
     return (
       <Column ref={this.setRef}>
         <ColumnHeader
@@ -91,7 +126,9 @@ export default class PublicTimeline extends React.PureComponent {
         </ColumnHeader>
 
         <StatusListContainer
-          timelineId='public'
+          prepend={headline}
+          alwaysPrepend
+          timelineId={`public${onlyMedia ? ':media' : ''}`}
           onLoadMore={this.handleLoadMore}
           trackScroll={!pinned}
           scrollKey={`public_timeline-${columnId}`}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index bb9b75505..9162e1326 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -155,7 +155,7 @@ export default class ActionBar extends React.PureComponent {
       <div className='detailed-status__action-bar'>
         <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
         <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
-        <div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
         {shareButton}
 
         <div className='detailed-status__action-bar-dropdown'>
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b5f516032..417719004 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -34,8 +34,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
     e.stopPropagation();
   }
 
-  handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  handleOpenVideo = (media, startTime) => {
+    this.props.onOpenVideo(media, startTime);
   }
 
   handleExpandedToggle = () => {
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index d5af2a459..505a88a3f 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -1,3 +1,4 @@
+import Immutable from 'immutable';
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
@@ -54,11 +55,44 @@ const messages = defineMessages({
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
-  const mapStateToProps = (state, props) => ({
-    status: getStatus(state, props.params.statusId),
-    ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
-    descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
-  });
+  const mapStateToProps = (state, props) => {
+    const status = getStatus(state, props.params.statusId);
+    let ancestorsIds = Immutable.List();
+    let descendantsIds = Immutable.List();
+
+    if (status) {
+      ancestorsIds = ancestorsIds.withMutations(mutable => {
+        let id = status.get('in_reply_to_id');
+
+        while (id) {
+          mutable.unshift(id);
+          id = state.getIn(['contexts', 'inReplyTos', id]);
+        }
+      });
+
+      descendantsIds = descendantsIds.withMutations(mutable => {
+        const ids = [status.get('id')];
+
+        while (ids.length > 0) {
+          let id        = ids.shift();
+          const replies = state.getIn(['contexts', 'replies', id]);
+
+          if (replies) {
+            replies.forEach(reply => {
+              mutable.push(reply);
+              ids.unshift(reply);
+            });
+          }
+        }
+      });
+    }
+
+    return {
+      status,
+      ancestorsIds,
+      descendantsIds,
+    };
+  };
 
   return mapStateToProps;
 };
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 0a62cbbeb..3ab867b5a 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -175,10 +175,11 @@ export default class ColumnsArea extends ImmutablePureComponent {
       <div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}>
         {columns.map(column => {
           const params = column.get('params', null) === null ? null : column.get('params').toJS();
+          const other  = params && params.other ? params.other : {};
 
           return (
             <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
-              {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />}
+              {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
             </BundleContainer>
           );
         })}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index fb76270fa..f4d6b5c4e 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -2,6 +2,7 @@ import React from 'react';
 import ReactSwipeableViews from 'react-swipeable-views';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
+import Video from '../../video';
 import ExtendedVideoPlayer from '../../../components/extended_video_player';
 import classNames from 'classnames';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -112,6 +113,22 @@ export default class MediaModal extends ImmutablePureComponent {
             onClick={this.toggleNavigation}
           />
         );
+      } else if (image.get('type') === 'video') {
+        const { time } = this.props;
+
+        return (
+          <Video
+            preview={image.get('preview_url')}
+            src={image.get('url')}
+            width={image.get('width')}
+            height={image.get('height')}
+            startTime={time || 0}
+            onCloseVideo={onClose}
+            detailed
+            description={image.get('description')}
+            key={image.get('url')}
+          />
+        );
       } else if (image.get('type') === 'gifv') {
         return (
           <ExtendedVideoPlayer
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 4185cba32..a334318ce 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -40,6 +40,17 @@ export default class ModalRoot extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
+  getSnapshotBeforeUpdate () {
+    const visible = !!this.props.type;
+    return {
+      overflowY: visible ? 'hidden' : null,
+    };
+  }
+
+  componentDidUpdate (prevProps, prevState, { overflowY }) {
+    document.body.style.overflowY = overflowY;
+  }
+
   renderLoading = modalId => () => {
     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
   }
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js
index 8a55c553c..8616f0315 100644
--- a/app/javascript/mastodon/features/ui/components/report_modal.js
+++ b/app/javascript/mastodon/features/ui/components/report_modal.js
@@ -30,7 +30,7 @@ const makeMapStateToProps = () => {
       account: getAccount(state, accountId),
       comment: state.getIn(['reports', 'new', 'comment']),
       forward: state.getIn(['reports', 'new', 'forward']),
-      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
     };
   };
 
@@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(expandAccountTimeline(this.props.account.get('id')));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id')));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
     }
   }
 
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
index 4efacda65..e5b1edc4a 100644
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -21,6 +21,8 @@ const makeGetStatusIds = () => createSelector([
   }
 
   return statusIds.filter(id => {
+    if (id === null) return true;
+
     const statusForId = statuses.get(id);
     let showStatus    = true;
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index adca0d617..adb856b92 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -141,7 +141,9 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
           <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
           <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
-          <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
+          <WrappedRoute path='/timelines/public/media' component={PublicTimeline} content={children} componentParams={{ onlyMedia: true }} />
+          <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
+          <WrappedRoute path='/timelines/public/local/media' component={CommunityTimeline} content={children} componentParams={{ onlyMedia: true }} />
           <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} />
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 98ebcb6f9..47a165e16 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { fromJS } from 'immutable';
 import { throttle } from 'lodash';
 import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
@@ -131,6 +132,8 @@ export default class Video extends React.PureComponent {
     this.seek = c;
   }
 
+  handleClickRoot = e => e.stopPropagation();
+
   handlePlay = () => {
     this.setState({ paused: false });
   }
@@ -244,8 +247,17 @@ export default class Video extends React.PureComponent {
   }
 
   handleOpenVideo = () => {
+    const { src, preview, width, height } = this.props;
+    const media = fromJS({
+      type: 'video',
+      url: src,
+      preview_url: preview,
+      width,
+      height,
+    });
+
     this.video.pause();
-    this.props.onOpenVideo(this.video.currentTime);
+    this.props.onOpenVideo(media, this.video.currentTime);
   }
 
   handleCloseVideo = () => {
@@ -270,7 +282,16 @@ export default class Video extends React.PureComponent {
     }
 
     return (
-      <div className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen })} style={playerStyle} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+      <div
+        role='menuitem'
+        className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen })}
+        style={playerStyle}
+        ref={this.setPlayerRef}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        onClick={this.handleClickRoot}
+        tabIndex={0}
+      >
         <video
           ref={this.setVideoRef}
           src={src}
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 947348f70..c8e37a366 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "روبوت",
   "account.block": "حظر @{name}",
   "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}",
   "account.blocked": "محظور",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "التصفح",
   "column_subheading.settings": "الإعدادات",
   "compose_form.direct_message_warning": "لن يَظهر هذا التبويق إلا للمستخدمين المذكورين.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "هذا التبويق لن يُدرَج تحت أي وسم كان بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن التبويقات العمومية عن طريق الوسوم.",
   "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
   "compose_form.lock_disclaimer.lock": "مقفل",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "الترقيّات:",
   "notifications.column_settings.show": "إعرِضها في عمود",
   "notifications.column_settings.sound": "أصدر صوتا",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "تم",
   "onboarding.next": "التالي",
   "onboarding.page_five.public_timelines": "تُعرَض في الخيط الزمني المحلي المشاركات العامة المحررة من طرف جميع المسجلين في {domain}. أما في الخيط الزمني الموحد ، فإنه يتم عرض جميع المشاركات العامة المنشورة من طرف جميع الأشخاص المتابَعين من طرف أعضاء {domain}. هذه هي الخيوط الزمنية العامة، وهي طريقة رائعة للتعرف أشخاص جدد.",
@@ -258,7 +261,7 @@
   "status.pin": "تدبيس على الملف الشخصي",
   "status.pinned": "تبويق مثبَّت",
   "status.reblog": "رَقِّي",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "القيام بالترقية إلى الجمهور الأصلي",
   "status.reblogged_by": "{name} رقى",
   "status.reply": "ردّ",
   "status.replyAll": "رُد على الخيط",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "المحلي",
   "tabs_bar.notifications": "الإخطارات",
   "tabs_bar.search": "البحث",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.",
   "upload_area.title": "إسحب ثم أفلت للرفع",
   "upload_button.label": "إضافة وسائط",
   "upload_form.description": "وصف للمعاقين بصريا",
   "upload_form.focus": "قص",
-  "upload_form.undo": "إلغاء",
+  "upload_form.undo": "حذف",
   "upload_progress.label": "يرفع...",
   "video.close": "إغلاق الفيديو",
   "video.exit_fullscreen": "الخروج من وضع الشاشة المليئة",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 971475114..9ac5aad9e 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Блокирай",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Споделяния:",
   "notifications.column_settings.show": "Покажи в колона",
   "notifications.column_settings.sound": "Play sound",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Done",
   "onboarding.next": "Next",
   "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Известия",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Добави медия",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index f2e3699d5..8fd42ca34 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Bloca @{name}",
   "account.block_domain": "Amaga-ho tot de {domain}",
   "account.blocked": "Bloquejat",
@@ -59,7 +60,8 @@
   "column_header.unpin": "No fixis",
   "column_subheading.navigation": "Navegació",
   "column_subheading.settings": "Configuració",
-  "compose_form.direct_message_warning": "Aquest toot només serà visible per a tots els usuaris esmentats.",
+  "compose_form.direct_message_warning": "Aquest toot només serà enviat als usuaris esmentats. De totes maneres, els operadors de la teva o de qualsevol de les instàncies receptores poden inspeccionar aquest missatge.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Aquest toot no es mostrarà en cap etiqueta ja que no està llistat. Només els toots públics poden ser cercats per etiqueta.",
   "compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
   "compose_form.lock_disclaimer.lock": "blocat",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Impulsos:",
   "notifications.column_settings.show": "Mostrar en la columna",
   "notifications.column_settings.sound": "Reproduïr so",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Fet",
   "onboarding.next": "Següent",
   "onboarding.page_five.public_timelines": "La línia de temps local mostra missatges públics de tothom de {domain}. La línia de temps federada mostra els missatges públics de tothom que la gent de {domain} segueix. Aquests són les línies de temps Públiques, una bona manera de descobrir noves persones.",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificacions",
   "tabs_bar.search": "Cerca",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "El vostre esborrany es perdrà si sortiu de Mastodon.",
   "upload_area.title": "Arrossega i deixa anar per carregar",
   "upload_button.label": "Afegir multimèdia",
   "upload_form.description": "Descriure els problemes visuals",
   "upload_form.focus": "Retallar",
-  "upload_form.undo": "Desfer",
+  "upload_form.undo": "Esborra",
   "upload_progress.label": "Pujant...",
   "video.close": "Tancar el vídeo",
   "video.exit_fullscreen": "Surt de pantalla completa",
diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json
new file mode 100644
index 000000000..bc75813ea
--- /dev/null
+++ b/app/javascript/mastodon/locales/co.json
@@ -0,0 +1,301 @@
+{
+  "account.badges.bot": "Bot",
+  "account.block": "Bluccà @{name}",
+  "account.block_domain": "Piattà tuttu da {domain}",
+  "account.blocked": "Bluccatu",
+  "account.direct": "Missaghju direttu @{name}",
+  "account.disclaimer_full": "Ghjè pussibule chì l’infurmazione quì sottu ùn rifletta micca u prufile sanu di l’utilizatore.",
+  "account.domain_blocked": "Duminiu piattatu",
+  "account.edit_profile": "Mudificà u prufile",
+  "account.follow": "Siguità",
+  "account.followers": "Abbunati",
+  "account.follows": "Abbunamenti",
+  "account.follows_you": "Vi seguita",
+  "account.hide_reblogs": "Piattà spartere da @{name}",
+  "account.media": "Media",
+  "account.mention": "Mintuvà @{name}",
+  "account.moved_to": "{name} hè partutu nant'à:",
+  "account.mute": "Piattà @{name}",
+  "account.mute_notifications": "Piattà nutificazione da @{name}",
+  "account.muted": "Piattatu",
+  "account.posts": "Statuti",
+  "account.posts_with_replies": "Statuti è risposte",
+  "account.report": "Palisà @{name}",
+  "account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
+  "account.share": "Sparte u prufile di @{name}",
+  "account.show_reblogs": "Vede spartere da @{name}",
+  "account.unblock": "Sbluccà @{name}",
+  "account.unblock_domain": "Ùn piattà più {domain}",
+  "account.unfollow": "Ùn siguità più",
+  "account.unmute": "Ùn piattà più @{name}",
+  "account.unmute_notifications": "Ùn piattà più nutificazione da @{name}",
+  "account.view_full_profile": "Vede tuttu u prufile",
+  "alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
+  "alert.unexpected.title": "Uups!",
+  "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.",
+  "bundle_column_error.retry": "Pruvà torna",
+  "bundle_column_error.title": "Errore di cunnessione",
+  "bundle_modal_error.close": "Chjudà",
+  "bundle_modal_error.message": "C'hè statu un prublemu caricandu st'elementu.",
+  "bundle_modal_error.retry": "Pruvà torna",
+  "column.blocks": "Utilizatori bluccati",
+  "column.community": "Linea pubblica lucale",
+  "column.direct": "Missaghji diretti",
+  "column.domain_blocks": "Duminii piattati",
+  "column.favourites": "Favuriti",
+  "column.follow_requests": "Dumande d'abbunamentu",
+  "column.home": "Accolta",
+  "column.lists": "Liste",
+  "column.mutes": "Utilizatori piattati",
+  "column.notifications": "Nutificazione",
+  "column.pins": "Statuti puntarulati",
+  "column.public": "Linea pubblica glubale",
+  "column_back_button.label": "Ritornu",
+  "column_header.hide_settings": "Piattà i parametri",
+  "column_header.moveLeft_settings": "Spiazzà à manca",
+  "column_header.moveRight_settings": "Spiazzà à diritta",
+  "column_header.pin": "Puntarulà",
+  "column_header.show_settings": "Mustrà i parametri",
+  "column_header.unpin": "Spuntarulà",
+  "column_subheading.navigation": "Navigazione",
+  "column_subheading.settings": "Parametri",
+  "compose_form.direct_message_warning": "Solu l'utilizatori mintuvati puderenu vede stu statutu.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
+  "compose_form.hashtag_warning": "Stu statutu ùn hè \"Micca listatu\" è ùn sarà micca listatu indè e circate da hashtag. Per esse vistu in quesse, u statutu deve esse \"Pubblicu\".",
+  "compose_form.lock_disclaimer": "U vostru contu ùn hè micca {locked}. Tuttu u mondu pò seguitavi è vede i vostri statuti privati.",
+  "compose_form.lock_disclaimer.lock": "privatu",
+  "compose_form.placeholder": "À chè pensate?",
+  "compose_form.publish": "Toot",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.marked": "Media indicatu cum'è sensibile",
+  "compose_form.sensitive.unmarked": "Media micca indicatu cum'è sensibile",
+  "compose_form.spoiler.marked": "Testu piattatu daret'à un'avertimentu",
+  "compose_form.spoiler.unmarked": "Testu micca piattatu",
+  "compose_form.spoiler_placeholder": "Scrive u vostr'avertimentu quì",
+  "confirmation_modal.cancel": "Annullà",
+  "confirmations.block.confirm": "Bluccà",
+  "confirmations.block.message": "Site sicuru·a che vulete bluccà @{name}?",
+  "confirmations.delete.confirm": "Toglie",
+  "confirmations.delete.message": "Site sicuru·a che vulete supprime stu statutu?",
+  "confirmations.delete_list.confirm": "Toglie",
+  "confirmations.delete_list.message": "Site sicuru·a che vulete supprime sta lista?",
+  "confirmations.domain_block.confirm": "Piattà tuttu u duminiu",
+  "confirmations.domain_block.message": "Site sicuru·a che vulete piattà tuttu à {domain}? Saria forse abbastanza di bluccà ò piattà alcuni conti da quallà.",
+  "confirmations.mute.confirm": "Piattà",
+  "confirmations.mute.message": "Site sicuru·a che vulete piattà @{name}?",
+  "confirmations.unfollow.confirm": "Disabbunassi",
+  "confirmations.unfollow.message": "Site sicuru·a ch'ùn vulete più siguità @{name}?",
+  "embed.instructions": "Integrà stu statutu à u vostru situ cù u codice quì sottu.",
+  "embed.preview": "Assumiglierà à qualcosa cusì:",
+  "emoji_button.activity": "Attività",
+  "emoji_button.custom": "Persunalizati",
+  "emoji_button.flags": "Bandere",
+  "emoji_button.food": "Manghjusca è Bienda",
+  "emoji_button.label": "Mette un'emoji",
+  "emoji_button.nature": "Natura",
+  "emoji_button.not_found": "Ùn c'hè nunda! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Oggetti",
+  "emoji_button.people": "Parsunaghji",
+  "emoji_button.recent": "Assai utilizati",
+  "emoji_button.search": "Cercà...",
+  "emoji_button.search_results": "Risultati di a cerca",
+  "emoji_button.symbols": "Simbuli",
+  "emoji_button.travel": "Lochi è Viaghju",
+  "empty_column.community": "Ùn c'hè nunda indè a linea lucale. Scrivete puru qualcosa!",
+  "empty_column.direct": "Ùn avete ancu nisun missaghju direttu. S'è voi mandate o ricevete unu, u vidarete quì.",
+  "empty_column.hashtag": "Ùn c'hè ancu nunda quì.",
+  "empty_column.home": "A vostr'accolta hè viota! Pudete andà nant'à {public} o pruvà a ricerca per truvà parsone da siguità.",
+  "empty_column.home.public_timeline": "a linea pubblica",
+  "empty_column.list": "Ùn c'hè ancu nunda quì. Quandu membri di sta lista manderanu novi statuti, i vidarete quì.",
+  "empty_column.notifications": "Ùn avete ancu nisuna nutificazione. Interact with others to start the conversation.",
+  "empty_column.public": "Ùn c'hè nunda quì! Scrivete qualcosa in pubblicu o seguitate utilizatori d'altre istanze per empie a linea pubblica",
+  "follow_request.authorize": "Auturizà",
+  "follow_request.reject": "Righjittà",
+  "getting_started.appsshort": "Applicazione",
+  "getting_started.faq": "FAQ",
+  "getting_started.heading": "Per principià",
+  "getting_started.open_source_notice": "Mastodon ghjè un lugiziale liberu. Pudete cuntribuisce à u codice o a traduzione, o palisà un bug, nant'à GitHub: {github}.",
+  "getting_started.userguide": "Guida d'utilizazione",
+  "home.column_settings.advanced": "Avanzati",
+  "home.column_settings.basic": "Bàsichi",
+  "home.column_settings.filter_regex": "Filtrà cù spressione regulare (regex)",
+  "home.column_settings.show_reblogs": "Vede e spartere",
+  "home.column_settings.show_replies": "Vede e risposte",
+  "home.settings": "Parametri di a colonna",
+  "keyboard_shortcuts.back": "rivultà",
+  "keyboard_shortcuts.boost": "sparte",
+  "keyboard_shortcuts.column": "fucalizà un statutu indè una colonna",
+  "keyboard_shortcuts.compose": "fucalizà nant'à l'area di ridazzione",
+  "keyboard_shortcuts.description": "Descrizzione",
+  "keyboard_shortcuts.down": "falà indè a lista",
+  "keyboard_shortcuts.enter": "apre u statutu",
+  "keyboard_shortcuts.favourite": "aghjunghje à i favuriti",
+  "keyboard_shortcuts.heading": "Accorte cù a tastera",
+  "keyboard_shortcuts.hotkey": "Accorta",
+  "keyboard_shortcuts.legend": "vede a legenda",
+  "keyboard_shortcuts.mention": "mintuvà l'autore",
+  "keyboard_shortcuts.reply": "risponde",
+  "keyboard_shortcuts.search": "fucalizà nant'à l'area di circata",
+  "keyboard_shortcuts.toggle_hidden": "vede/piattà u testu daretu à l'avertimentu CW",
+  "keyboard_shortcuts.toot": "scrive un novu statutu",
+  "keyboard_shortcuts.unfocus": "ùn fucalizà più l'area di testu",
+  "keyboard_shortcuts.up": "cullà indè a lista",
+  "lightbox.close": "Chjudà",
+  "lightbox.next": "Siguente",
+  "lightbox.previous": "Pricidente",
+  "lists.account.add": "Aghjunghje à a lista",
+  "lists.account.remove": "Toglie di a lista",
+  "lists.delete": "Supprime a lista",
+  "lists.edit": "Mudificà a lista",
+  "lists.new.create": "Aghjustà una lista",
+  "lists.new.title_placeholder": "Titulu di a lista",
+  "lists.search": "Circà indè i vostr'abbunamenti",
+  "lists.subheading": "E vo liste",
+  "loading_indicator.label": "Caricamentu...",
+  "media_gallery.toggle_visible": "Cambià a visibilità",
+  "missing_indicator.label": "Micca trovu",
+  "missing_indicator.sublabel": "Ùn era micca pussivule di truvà sta risorsa",
+  "mute_modal.hide_notifications": "Piattà nutificazione da st'utilizatore?",
+  "navigation_bar.blocks": "Utilizatori bluccati",
+  "navigation_bar.community_timeline": "Linea pubblica lucale",
+  "navigation_bar.direct": "Missaghji diretti",
+  "navigation_bar.domain_blocks": "Duminii piattati",
+  "navigation_bar.edit_profile": "Mudificà u prufile",
+  "navigation_bar.favourites": "Favuriti",
+  "navigation_bar.follow_requests": "Dumande d'abbunamentu",
+  "navigation_bar.info": "À prupositu di l'istanza",
+  "navigation_bar.keyboard_shortcuts": "Accorte cù a tastera",
+  "navigation_bar.lists": "Liste",
+  "navigation_bar.logout": "Scunnettassi",
+  "navigation_bar.mutes": "Utilizatori piattati",
+  "navigation_bar.pins": "Statuti puntarulati",
+  "navigation_bar.preferences": "Preferenze",
+  "navigation_bar.public_timeline": "Linea pubblica glubale",
+  "notification.favourite": "{name} hà aghjuntu u vostru statutu à i so favuriti",
+  "notification.follow": "{name} v'hà seguitatu",
+  "notification.mention": "{name} v'hà mintuvatu",
+  "notification.reblog": "{name} hà spartutu u vostru statutu",
+  "notifications.clear": "Purgà e nutificazione",
+  "notifications.clear_confirmation": "Site sicuru·a che vulete toglie tutte ste nutificazione?",
+  "notifications.column_settings.alert": "Nutificazione nant'à l'urdinatore",
+  "notifications.column_settings.favourite": "Favuriti:",
+  "notifications.column_settings.follow": "Abbunati novi:",
+  "notifications.column_settings.mention": "Minzione:",
+  "notifications.column_settings.push": "Nutificazione Push",
+  "notifications.column_settings.push_meta": "Quess'apparechju",
+  "notifications.column_settings.reblog": "Spartere:",
+  "notifications.column_settings.show": "Mustrà indè a colonna",
+  "notifications.column_settings.sound": "Sunà",
+  "notifications.group": "{count} notifications",
+  "onboarding.done": "Fatta",
+  "onboarding.next": "Siguente",
+  "onboarding.page_five.public_timelines": "A linea pubblica lucale mostra statuti pubblichi da tuttu u mondu nant'à {domain}. A linea pubblica glubale mostra ancu quelli di a ghjente seguitata da l'utilizatori di {domain}. Quesse sò una bona manera d'incuntrà nove parsone.",
+  "onboarding.page_four.home": "A linea d'accolta mostra i statuti di i vostr'abbunamenti.",
+  "onboarding.page_four.notifications": "A colonna di nutificazione mostra l'interazzione ch'altre parsone anu cù u vostru contu.",
+  "onboarding.page_one.federation": "Mastodon ghjè una rete di servori independenti, chjamati istanze, uniti indè una sola rete suciale.",
+  "onboarding.page_one.full_handle": "U vostru identificatore cumplettu",
+  "onboarding.page_one.handle_hint": "Quessu ghjè cio chì direte à i vostri amichi per circavi.",
+  "onboarding.page_one.welcome": "Benvenuti/a nant'à Mastodon!",
+  "onboarding.page_six.admin": "L'amministratore di a vostr'istanza hè {admin}.",
+  "onboarding.page_six.almost_done": "Quasgi finitu...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "Ci sò {apps} dispunibule per iOS, Android è altre piattaforme.",
+  "onboarding.page_six.github": "Mastodon ghjè un lugiziale liberu. Pudete cuntribuisce à u codice o a traduzione, o palisà un prublemu, nant'à {github}.",
+  "onboarding.page_six.guidelines": "regule di a cumunità",
+  "onboarding.page_six.read_guidelines": "Ùn vi scurdate di leghje e {guidelines} di {domain}!",
+  "onboarding.page_six.various_app": "applicazione pè u telefuninu",
+  "onboarding.page_three.profile": "Pudete mudificà u prufile per cambia u ritrattu, a descrizzione è u nome affissatu. Ci sò ancu alcun'altre preferenze.",
+  "onboarding.page_three.search": "Fate usu di l'area di ricerca per truvà altre persone è vede hashtag cum'è {illustration} o {introductions}. Per vede qualcunu ch'ùn hè micca nant'à st'istanza, cercate u so identificatore complettu (pare un'email).",
+  "onboarding.page_two.compose": "I statuti è missaghji si scrivenu indè l'area di ridazzione. Pudete caricà imagine, cambià i parametri di pubblicazione, è mette avertimenti di cuntenuti cù i buttoni quì sottu.",
+  "onboarding.skip": "Passà",
+  "privacy.change": "Mudificà a cunfidenzialità di u statutu",
+  "privacy.direct.long": "Mandà solu à quelli chì so mintuvati",
+  "privacy.direct.short": "Direttu",
+  "privacy.private.long": "Mustrà solu à l'abbunati",
+  "privacy.private.short": "Privatu",
+  "privacy.public.long": "Mustrà à tuttu u mondu nant'a linea pubblica",
+  "privacy.public.short": "Pubblicu",
+  "privacy.unlisted.long": "Ùn mette micca nant'a linea pubblica (ma tutt'u mondu pò vede u statutu nant'à u vostru prufile)",
+  "privacy.unlisted.short": "Micca listatu",
+  "regeneration_indicator.label": "Caricamentu…",
+  "regeneration_indicator.sublabel": "Priparazione di a vostra pagina d'accolta",
+  "relative_time.days": "{number}d",
+  "relative_time.hours": "{number}h",
+  "relative_time.just_now": "avà",
+  "relative_time.minutes": "{number}m",
+  "relative_time.seconds": "{number}s",
+  "reply_indicator.cancel": "Annullà",
+  "report.forward": "Trasferisce à {target}",
+  "report.forward_hint": "U contu hè nant'à un'altru servore. Vulete ancu mandà una copia anonima di u signalamentu quallà?",
+  "report.hint": "U signalamentu sarà mandatu à i muderatori di l'istanza. Pudete spiegà perchè avete palisatu stu contu quì sottu:",
+  "report.placeholder": "Altri cummenti",
+  "report.submit": "Mandà",
+  "report.target": "Signalamentu",
+  "search.placeholder": "Circà",
+  "search_popout.search_format": "Ricerca avanzata",
+  "search_popout.tips.full_text": "I testi simplici rimandanu i statuti ch'avete scritti, aghjunti à i vostri favuriti, spartuti o induve quelli site mintuvatu·a, è ancu i cugnomi, nomi pubblichi è hashtag chì currispondenu.",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "statutu",
+  "search_popout.tips.text": "Un testu simplice rimanda i nomi pubblichi, cugnomi è hashtag",
+  "search_popout.tips.user": "utilizatore",
+  "search_results.accounts": "Ghjente",
+  "search_results.hashtags": "Hashtag",
+  "search_results.statuses": "Statuti",
+  "search_results.total": "{count, number} {count, plural, one {risultatu} other {risultati}}",
+  "standalone.public_title": "Una vista di...",
+  "status.block": "Bluccà @{name}",
+  "status.cancel_reblog_private": "Ùn sparte più",
+  "status.cannot_reblog": "Stu statutu ùn pò micca esse spartutu",
+  "status.delete": "Toglie",
+  "status.direct": "Mandà un missaghju @{name}",
+  "status.embed": "Integrà",
+  "status.favourite": "Aghjunghje à i favuriti",
+  "status.load_more": "Vede di più",
+  "status.media_hidden": "Media piattata",
+  "status.mention": "Mintuvà @{name}",
+  "status.more": "Più",
+  "status.mute": "Piattà @{name}",
+  "status.mute_conversation": "Piattà a cunversazione",
+  "status.open": "Apre stu statutu",
+  "status.pin": "Puntarulà à u prufile",
+  "status.pinned": "Statutu puntarulatu",
+  "status.reblog": "Sparte",
+  "status.reblog_private": "Sparte à l'audienza uriginale",
+  "status.reblogged_by": "{name} hà spartutu",
+  "status.reply": "Risponde",
+  "status.replyAll": "Risponde à tutti",
+  "status.report": "Palisà @{name}",
+  "status.sensitive_toggle": "Cliccate per vede",
+  "status.sensitive_warning": "Cuntinutu sensibile",
+  "status.share": "Sparte",
+  "status.show_less": "Ripiegà",
+  "status.show_less_all": "Ripiegà tuttu",
+  "status.show_more": "Slibrà",
+  "status.show_more_all": "Slibrà tuttu",
+  "status.unmute_conversation": "Ùn piattà più a cunversazione",
+  "status.unpin": "Spuntarulà da u prufile",
+  "tabs_bar.federated_timeline": "Glubale",
+  "tabs_bar.home": "Accolta",
+  "tabs_bar.local_timeline": "Lucale",
+  "tabs_bar.notifications": "Nutificazione",
+  "tabs_bar.search": "Cercà",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
+  "ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.",
+  "upload_area.title": "Drag & drop per caricà un fugliale",
+  "upload_button.label": "Aghjunghje un media",
+  "upload_form.description": "Discrive per i malvistosi",
+  "upload_form.focus": "Riquatrà",
+  "upload_form.undo": "Annullà",
+  "upload_progress.label": "Caricamentu...",
+  "video.close": "Chjudà a video",
+  "video.exit_fullscreen": "Caccià u pienu screnu",
+  "video.expand": "Ingrandà a video",
+  "video.fullscreen": "Pienu screnu",
+  "video.hide": "Piattà a video",
+  "video.mute": "Surdina",
+  "video.pause": "Pausa",
+  "video.play": "Lettura",
+  "video.unmute": "Caccià a surdina"
+}
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index f442e0675..29756aff0 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "@{name} blocken",
   "account.block_domain": "Alles von {domain} verstecken",
   "account.blocked": "Blockiert",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Einstellungen",
   "compose_form.direct_message_warning": "Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Dieser Beitrag wird nicht unter einen dieser Hashtags sichtbar sein, solange er ungelistet ist. Bei einer Suche kann er nicht gefunden werden.",
   "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
   "compose_form.lock_disclaimer.lock": "gesperrt",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Geteilte Beiträge:",
   "notifications.column_settings.show": "In der Spalte anzeigen",
   "notifications.column_settings.sound": "Ton abspielen",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Fertig",
   "onboarding.next": "Weiter",
   "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen sind sie die öffentlichen Zeitleisten, ein guter Weg, um neue Leute zu finden.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Mitteilungen",
   "tabs_bar.search": "Suchen",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
   "upload_area.title": "Zum Hochladen hereinziehen",
   "upload_button.label": "Mediendatei hinzufügen",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index b400349d2..ef2a796a3 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -17,6 +17,10 @@
       {
         "defaultMessage": "{name} mentioned you",
         "id": "notification.mention"
+      },
+      {
+        "defaultMessage": "{count} notifications",
+        "id": "notifications.group"
       }
     ],
     "path": "app/javascript/mastodon/actions/notifications.json"
@@ -552,6 +556,10 @@
       {
         "defaultMessage": "Domain hidden",
         "id": "account.domain_blocked"
+      },
+      {
+        "defaultMessage": "Bot",
+        "id": "account.badges.bot"
       }
     ],
     "path": "app/javascript/mastodon/features/account/components/header.json"
@@ -585,6 +593,19 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Toots",
+        "id": "timeline.posts"
+      },
+      {
+        "defaultMessage": "Media",
+        "id": "timeline.media"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/community_timeline/components/section_headline.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Local timeline",
         "id": "column.community"
       },
@@ -815,7 +836,7 @@
         "id": "upload_form.description"
       },
       {
-        "defaultMessage": "Undo",
+        "defaultMessage": "Delete",
         "id": "upload_form.undo"
       },
       {
@@ -866,8 +887,12 @@
         "id": "compose_form.hashtag_warning"
       },
       {
-        "defaultMessage": "This toot will only be visible to all the mentioned users.",
+        "defaultMessage": "This toot will only be sent to all the mentioned users.",
         "id": "compose_form.direct_message_warning"
+      },
+      {
+        "defaultMessage": "Learn more",
+        "id": "compose_form.direct_message_warning_learn_more"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
index a7e1c408f..bb48fe93f 100644
--- a/app/javascript/mastodon/locales/el.json
+++ b/app/javascript/mastodon/locales/el.json
@@ -1,37 +1,38 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Απόκλεισε τον/την @{name}",
   "account.block_domain": "Απόκρυψε τα πάντα από τον/την",
   "account.blocked": "Αποκλεισμένος/η",
-  "account.direct": "Απευθείας μήνυμα προς @{name}",
+  "account.direct": "Άμεσο μήνυμα προς @{name}",
   "account.disclaimer_full": "Οι παρακάτω πληροφορίες μπορει να μην αντανακλούν το προφίλ του χρήστη επαρκως.",
-  "account.domain_blocked": "Domain hidden",
+  "account.domain_blocked": "Κρυμμένος τομέας",
   "account.edit_profile": "Επεξεργάσου το προφίλ",
   "account.follow": "Ακολούθησε",
   "account.followers": "Ακόλουθοι",
   "account.follows": "Ακολουθεί",
   "account.follows_you": "Σε ακολουθεί",
-  "account.hide_reblogs": "Απόκρυψη προωθήσεων από τον/την @{name}",
+  "account.hide_reblogs": "Απόκρυψη προωθήσεων από @{name}",
   "account.media": "Πολυμέσα",
-  "account.mention": "Ανέφερε τον/την @{name}",
-  "account.moved_to": "{name} μετακόμισε στο:",
+  "account.mention": "Ανάφερε @{name}",
+  "account.moved_to": "{name} μεταφέρθηκε στο:",
   "account.mute": "Σώπασε τον/την @{name}",
   "account.mute_notifications": "Σώπασε τις ειδοποιήσεις από τον/την @{name}",
   "account.muted": "Αποσιωπημένος/η",
   "account.posts": "Τουτ",
   "account.posts_with_replies": "Τουτ και απαντήσεις",
-  "account.report": "Ανέφερε τον/την @{name}",
+  "account.report": "Κατάγγειλε τον/την @{name}",
   "account.requested": "Εκκρεμεί έγκριση. Κάνε κλικ για να ακυρώσεις το αίτημα ακολούθησης",
   "account.share": "Μοιράσου το προφίλ του/της @{name}",
   "account.show_reblogs": "Δείξε τις προωθήσεις του/της @{name}",
-  "account.unblock": "Unblock @{name}",
+  "account.unblock": "Ξεμπλόκαρε τον/την @{name}",
   "account.unblock_domain": "Αποκάλυψε το {domain}",
-  "account.unfollow": "Unfollow",
-  "account.unmute": "Unmute @{name}",
-  "account.unmute_notifications": "Unmute notifications from @{name}",
+  "account.unfollow": "Διακοπή παρακολούθησης",
+  "account.unmute": "Διακοπή αποσιώπησης του/της @{name}",
+  "account.unmute_notifications": "Διακοπή αποσιώπησης ειδοποιήσεων του/της @{name}",
   "account.view_full_profile": "Δες το πλήρες προφίλ",
   "alert.unexpected.message": "Προέκυψε απροσδόκητο σφάλμα.",
   "alert.unexpected.title": "Εεπ!",
-  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "boost_modal.combo": "Μπορείς να πατήσεις {combo} για να το προσπεράσεις αυτό την επόμενη φορά",
   "bundle_column_error.body": "Κάτι πήγε στραβά ενώ φορτωνόταν αυτό το στοιχείο.",
   "bundle_column_error.retry": "Δοκίμασε ξανά",
   "bundle_column_error.title": "Σφάλμα δικτύου",
@@ -41,9 +42,9 @@
   "column.blocks": "Αποκλεισμένοι χρήστες",
   "column.community": "Τοπική ροή",
   "column.direct": "Απευθείας μηνύματα",
-  "column.domain_blocks": "Hidden domains",
+  "column.domain_blocks": "Κρυμμένοι τομείς",
   "column.favourites": "Αγαπημένα",
-  "column.follow_requests": "Αιτήματα παρακολούθησης",
+  "column.follow_requests": "Αιτήματα ακολούθησης",
   "column.home": "Αρχική",
   "column.lists": "Λίστες",
   "column.mutes": "Αποσιωπημένοι χρήστες",
@@ -59,8 +60,9 @@
   "column_header.unpin": "Ξεκαρφίτσωμα",
   "column_subheading.navigation": "Πλοήγηση",
   "column_subheading.settings": "Ρυθμίσεις",
-  "compose_form.direct_message_warning": "Αυτό το τουτ θα εμφανίζεται μόνο σε όλους τους αναφερόμενους χρήστες.",
-  "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.direct_message_warning": "Αυτό το τουτ θα σταλεί μόνο στους αναφερόμενους χρήστες.",
+  "compose_form.direct_message_warning_learn_more": "Μάθετε περισσότερα",
+  "compose_form.hashtag_warning": "Αυτό το τουτ δεν θα εμφανίζεται κάτω από κανένα hashtag καθώς είναι αφανές. Μόνο τα δημόσια τουτ μπορούν να αναζητηθούν ανά hashtag.",
   "compose_form.lock_disclaimer": "Ο λογαριασμός σου δεν είναι {locked}. Οποιοσδήποτε μπορεί να σε ακολουθήσει για να δει τις δημοσιεύσεις σας προς τους ακολούθους σας.",
   "compose_form.lock_disclaimer.lock": "κλειδωμένος",
   "compose_form.placeholder": "Τι σκέφτεσαι;",
@@ -78,127 +80,128 @@
   "confirmations.delete.message": "Σίγουρα θες να διαγράψεις αυτή την κατάσταση;",
   "confirmations.delete_list.confirm": "Διέγραψε",
   "confirmations.delete_list.message": "Σίγουρα θες να διαγράψεις οριστικά αυτή τη λίστα;",
-  "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.mute.confirm": "Mute",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "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 emojos!! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.recent": "Frequently used",
-  "emoji_button.search": "Search...",
-  "emoji_button.search_results": "Search results",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
-  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
-  "empty_column.hashtag": "There is nothing in this hashtag yet.",
-  "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
-  "empty_column.home.public_timeline": "the public timeline",
-  "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
-  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
-  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
-  "follow_request.authorize": "Authorize",
-  "follow_request.reject": "Reject",
-  "getting_started.appsshort": "Apps",
-  "getting_started.faq": "FAQ",
-  "getting_started.heading": "Getting started",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
-  "getting_started.userguide": "User Guide",
-  "home.column_settings.advanced": "Advanced",
-  "home.column_settings.basic": "Basic",
-  "home.column_settings.filter_regex": "Filter out by regular expressions",
-  "home.column_settings.show_reblogs": "Show boosts",
-  "home.column_settings.show_replies": "Show replies",
-  "home.settings": "Column settings",
-  "keyboard_shortcuts.back": "to navigate back",
-  "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
-  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "confirmations.domain_block.confirm": "Απόκρυψη ολόκληρου του τομέα",
+  "confirmations.domain_block.message": "Σίγουρα θες να μπλοκάρεις ολόκληρο το {domain}; Συνήθως μερικά εστιασμένα μπλοκ ή αποσιωπήσεις επαρκούν και προτιμούνται.",
+  "confirmations.mute.confirm": "Αποσιώπηση",
+  "confirmations.mute.message": "Σίγουρα θες να αποσιωπήσεις τον/την {name};",
+  "confirmations.unfollow.confirm": "Διακοπή παρακολούθησης",
+  "confirmations.unfollow.message": "Σίγουρα θες να πάψεις να ακολουθείς τον/την {name};",
+  "embed.instructions": "Ενσωματώστε αυτή την κατάσταση στην ιστοσελίδα σας αντιγράφοντας τον παρακάτω κώδικα.",
+  "embed.preview": "Ορίστε πως θα φαίνεται:",
+  "emoji_button.activity": "Δραστηριότητα",
+  "emoji_button.custom": "Προσαρμοσμένα",
+  "emoji_button.flags": "Σημαίες",
+  "emoji_button.food": "Φαγητά & Ποτά",
+  "emoji_button.label": "Εισάγετε emoji",
+  "emoji_button.nature": "Φύση",
+  "emoji_button.not_found": "Ουδέν emojo!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Αντικείμενα",
+  "emoji_button.people": "Άνθρωποι",
+  "emoji_button.recent": "Δημοφιλή",
+  "emoji_button.search": "Αναζήτηση…",
+  "emoji_button.search_results": "Αποτελέσματα αναζήτησης",
+  "emoji_button.symbols": "Σύμβολα",
+  "emoji_button.travel": "Ταξίδια & Τοποθεσίες",
+  "empty_column.community": "Η τοπική ροή είναι κενή. Γράψε κάτι δημόσιο παραμύθι ν' αρχινίσει!",
+  "empty_column.direct": "Δεν έχεις απευθείας μηνύματα ακόμα. Όταν στείλεις ή λάβεις κανένα, θα εμφανιστεί εδώ.",
+  "empty_column.hashtag": "Δεν υπάρχει ακόμα κάτι για αυτή την ταμπέλα.",
+  "empty_column.home": "Η τοπική σου ροή είναι κενή! Πήγαινε στο {public} ή κάνε αναζήτηση για να ξεκινήσεις και να γνωρίσεις άλλους χρήστες.",
+  "empty_column.home.public_timeline": "η δημόσια ροή",
+  "empty_column.list": "Δεν υπάρχει τίποτα σε αυτή τη λίστα ακόμα. Όταν τα μέλη της δημοσιεύσουν νέες καταστάσεις, θα εμφανιστούν εδώ.",
+  "empty_column.notifications": "Δεν έχεις ειδοποιήσεις ακόμα. Αλληλεπίδρασε με άλλους χρήστες για να ξεκινήσεις την κουβέντα.",
+  "empty_column.public": "Δεν υπάρχει τίποτα εδώ! Γράψε κάτι δημόσιο, ή ακολούθησε χειροκίνητα χρήστες από άλλα instances για να τη γεμίσεις",
+  "follow_request.authorize": "Ενέκρινε",
+  "follow_request.reject": "Απέρριψε",
+  "getting_started.appsshort": "Εφαρμογές",
+  "getting_started.faq": "Συχνές Ερωτήσεις",
+  "getting_started.heading": "Ξεκινώντας",
+  "getting_started.open_source_notice": "Το Mastodon είναι ελεύθερο λογισμικό. Μπορείς να συνεισφέρεις ή να αναφέρεις ζητήματα στο GitHub στο {github}.",
+  "getting_started.userguide": "Οδηγός Χρήσης",
+  "home.column_settings.advanced": "Προχωρημένα",
+  "home.column_settings.basic": "Βασικά",
+  "home.column_settings.filter_regex": "Φιλτράρετε μέσω regular expressions",
+  "home.column_settings.show_reblogs": "Εμφάνιση προωθήσεων",
+  "home.column_settings.show_replies": "Εμφάνιση απαντήσεων",
+  "home.settings": "Ρυθμίσεις στηλών",
+  "keyboard_shortcuts.back": "για επιστροφή πίσω",
+  "keyboard_shortcuts.boost": "για προώθηση",
+  "keyboard_shortcuts.column": "για εστίαση μιας κατάστασης σε μια από τις στήλες",
+  "keyboard_shortcuts.compose": "για εστίαση στην περιοχή κειμένου συγγραφής",
   "keyboard_shortcuts.description": "Description",
-  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.down": "για κίνηση προς τα κάτω στη λίστα",
   "keyboard_shortcuts.enter": "to open status",
-  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.favourite": "για σημείωση αγαπημένου",
   "keyboard_shortcuts.heading": "Keyboard Shortcuts",
-  "keyboard_shortcuts.hotkey": "Hotkey",
-  "keyboard_shortcuts.legend": "to display this legend",
-  "keyboard_shortcuts.mention": "to mention author",
-  "keyboard_shortcuts.reply": "to reply",
-  "keyboard_shortcuts.search": "to focus search",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
-  "keyboard_shortcuts.toot": "to start a brand new toot",
-  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
-  "keyboard_shortcuts.up": "to move up in the list",
+  "keyboard_shortcuts.hotkey": "Συντόμευση",
+  "keyboard_shortcuts.legend": "για να εμφανίσεις αυτόν τον οδηγό",
+  "keyboard_shortcuts.mention": "για να αναφέρεις το συγγραφέα",
+  "keyboard_shortcuts.reply": "για απάντηση",
+  "keyboard_shortcuts.search": "για εστίαση αναζήτησης",
+  "keyboard_shortcuts.toggle_hidden": "για εμφάνιση/απόκρυψη κειμένου πίσω από την προειδοποίηση",
+  "keyboard_shortcuts.toot": "για δημιουργία ολοκαίνουριου τουτ",
+  "keyboard_shortcuts.unfocus": "για την απο-εστίαση του πεδίου σύνθεσης/αναζήτησης",
+  "keyboard_shortcuts.up": "για να ανέβεις στη λίστα",
   "lightbox.close": "Close",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
-  "lists.account.add": "Add to list",
-  "lists.account.remove": "Remove from list",
+  "lightbox.next": "Επόμενο",
+  "lightbox.previous": "Προηγούμενο",
+  "lists.account.add": "Πρόσθεσε στη λίστα",
+  "lists.account.remove": "Αφαίρεσε από τη λίστα",
   "lists.delete": "Delete list",
-  "lists.edit": "Edit list",
-  "lists.new.create": "Add list",
-  "lists.new.title_placeholder": "New list title",
-  "lists.search": "Search among people you follow",
-  "lists.subheading": "Your lists",
-  "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
-  "missing_indicator.label": "Not found",
-  "missing_indicator.sublabel": "This resource could not be found",
-  "mute_modal.hide_notifications": "Hide notifications from this user?",
-  "navigation_bar.blocks": "Blocked users",
+  "lists.edit": "Τροποποίησε τη λίστα",
+  "lists.new.create": "Πρόσθεσε λίστα",
+  "lists.new.title_placeholder": "Τίτλος νέας λίστα",
+  "lists.search": "Αναζήτησε ανάμεσα σε όσους/όσες ακολουθείς",
+  "lists.subheading": "Οι λίστες σου",
+  "loading_indicator.label": "Φορτώνει...",
+  "media_gallery.toggle_visible": "Αντιστροφή ορατότητας",
+  "missing_indicator.label": "Δε βρέθηκε",
+  "missing_indicator.sublabel": "Αυτό το υλικό δε βρέθηκε",
+  "mute_modal.hide_notifications": "Απόκρυψη ειδοποιήσεων αυτού του χρήστη;",
+  "navigation_bar.blocks": "Αποκλεισμένοι χρήστες",
   "navigation_bar.community_timeline": "Local timeline",
-  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.direct": "Απευθείας μηνύματα",
   "navigation_bar.domain_blocks": "Hidden domains",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.info": "Extended information",
-  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.keyboard_shortcuts": "Συντομεύσεις πληκτρολογίου",
   "navigation_bar.lists": "Lists",
-  "navigation_bar.logout": "Logout",
+  "navigation_bar.logout": "Αποσύνδεση",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.pins": "Pinned toots",
-  "navigation_bar.preferences": "Preferences",
-  "navigation_bar.public_timeline": "Federated timeline",
-  "notification.favourite": "{name} favourited your status",
-  "notification.follow": "{name} followed you",
-  "notification.mention": "{name} mentioned you",
-  "notification.reblog": "{name} boosted your status",
-  "notifications.clear": "Clear notifications",
-  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "navigation_bar.preferences": "Προτιμήσεις",
+  "navigation_bar.public_timeline": "Ομοσπονδιακή ροή",
+  "notification.favourite": "Ο/Η {name} σημείωσε ως αγαπημένη την κατάστασή σου",
+  "notification.follow": "Ο/Η {name} σε ακολούθησε",
+  "notification.mention": "Ο/Η {name} σε ανέφερε",
+  "notification.reblog": "Ο/Η {name} προώθησε την κατάστασή σου",
+  "notifications.clear": "Καθαρισμός ειδοποιήσεων",
+  "notifications.clear_confirmation": "Σίγουρα θέλεις να καθαρίσεις όλες τις ειδοποιήσεις σου;",
   "notifications.column_settings.alert": "Desktop notifications",
-  "notifications.column_settings.favourite": "Favourites:",
-  "notifications.column_settings.follow": "New followers:",
-  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.favourite": "Αγαπημένα:",
+  "notifications.column_settings.follow": "Νέοι ακόλουθοι:",
+  "notifications.column_settings.mention": "Αναφορές:",
   "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
-  "notifications.column_settings.reblog": "Boosts:",
-  "notifications.column_settings.show": "Show in column",
-  "notifications.column_settings.sound": "Play sound",
-  "onboarding.done": "Done",
-  "onboarding.next": "Next",
-  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "notifications.column_settings.push_meta": "Αυτή η συσκευή",
+  "notifications.column_settings.reblog": "Προωθήσεις:",
+  "notifications.column_settings.show": "Εμφάνισε σε στήλη",
+  "notifications.column_settings.sound": "Ηχητική ειδοποίηση",
+  "notifications.group": "{count} notifications",
+  "onboarding.done": "Έγινε",
+  "onboarding.next": "Επόμενο",
+  "onboarding.page_five.public_timelines": "Η τοπική ροή δείχνει τις δημόσιες δημοσιεύσεις από όσους εδρεύουν στον κόμβο {domain}. Η ομοσπονδιακή ροή δείχνει τις δημόσιες δημοσιεύσεις εκείνων που οι χρήστες του {domain} ακολουθούν. Αυτές οι είναι Δημόσιες Ροές, ένας ωραίος τρόπος να ανακαλύψεις καινούριους ανθρώπους.",
   "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
   "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
   "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
   "onboarding.page_one.full_handle": "Your full handle",
   "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
   "onboarding.page_one.welcome": "Welcome to Mastodon!",
-  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.admin": "Ο διαχειριστής του κόμβου σου είναι ο/η {admin}.",
   "onboarding.page_six.almost_done": "Almost done...",
   "onboarding.page_six.appetoot": "Bon Appetoot!",
   "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
-  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.github": "Το Mastodon είναι ελεύθερο λογισμικό. Μπορείς να αναφέρεις σφάλματα, να αιτηθείς νέες λειτουργίες ή να συνεισφέρεις κώδικα στο {github}.",
   "onboarding.page_six.guidelines": "community guidelines",
   "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
   "onboarding.page_six.various_app": "mobile apps",
@@ -224,13 +227,13 @@
   "relative_time.seconds": "{number}s",
   "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.forward_hint": "Ο λογαριασμός είναι από διαφορετικό διακομιστή. Να σταλεί ανώνυμο αντίγραφο της καταγγελίας κι εκεί;",
+  "report.hint": "Η καταγγελία θα σταλεί στους διαχειριστές του κόμβου σου. Μπορείς να περιγράψεις γιατί καταγγέλεις το λογαριασμό παρακάτω:",
   "report.placeholder": "Additional comments",
   "report.submit": "Submit",
-  "report.target": "Report {target}",
+  "report.target": "Καταγγελία {target}",
   "search.placeholder": "Search",
-  "search_popout.search_format": "Advanced search format",
+  "search_popout.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",
@@ -244,17 +247,17 @@
   "status.block": "Block @{name}",
   "status.cancel_reblog_private": "Unboost",
   "status.cannot_reblog": "This post cannot be boosted",
-  "status.delete": "Delete",
+  "status.delete": "Διαγραφή",
   "status.direct": "Direct message @{name}",
   "status.embed": "Embed",
   "status.favourite": "Favourite",
-  "status.load_more": "Load more",
+  "status.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.open": "Διεύρυνε αυτή την κατάσταση",
   "status.pin": "Pin on profile",
   "status.pinned": "Pinned toot",
   "status.reblog": "Boost",
@@ -262,7 +265,7 @@
   "status.reblogged_by": "{name} boosted",
   "status.reply": "Reply",
   "status.replyAll": "Reply to thread",
-  "status.report": "Report @{name}",
+  "status.report": "Καταγγελία @{name}",
   "status.sensitive_toggle": "Click to view",
   "status.sensitive_warning": "Sensitive content",
   "status.share": "Share",
@@ -272,17 +275,19 @@
   "status.show_more_all": "Show more for all",
   "status.unmute_conversation": "Unmute conversation",
   "status.unpin": "Unpin from profile",
-  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.federated_timeline": "Ομοσπονδιακή",
   "tabs_bar.home": "Home",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notifications",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
   "upload_form.description": "Describe for the visually impaired",
   "upload_form.focus": "Crop",
-  "upload_form.undo": "Undo",
+  "upload_form.undo": "Διαγραφή",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
   "video.exit_fullscreen": "Exit full screen",
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index d8e69fd3c..690904236 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
@@ -62,7 +63,8 @@
   "column_subheading.lists": "Lists",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "This toot will only be sent to the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
@@ -189,6 +191,7 @@
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Done",
   "onboarding.next": "Next",
   "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
@@ -281,12 +284,14 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notifications",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
   "upload_form.description": "Describe for the visually impaired",
   "upload_form.focus": "Crop",
-  "upload_form.undo": "Undo",
+  "upload_form.undo": "Delete",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
   "video.exit_fullscreen": "Exit full screen",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 37587c14c..bb27099b7 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -1,8 +1,9 @@
 {
+  "account.badges.bot": "Roboto",
   "account.block": "Bloki @{name}",
   "account.block_domain": "Kaŝi ĉion de {domain}",
   "account.blocked": "Blokita",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "Rekte mesaĝi @{name}",
   "account.disclaimer_full": "Subaj informoj povas reflekti la profilon de la uzanto nekomplete.",
   "account.domain_blocked": "Domajno kaŝita",
   "account.edit_profile": "Redakti profilon",
@@ -17,7 +18,7 @@
   "account.mute": "Silentigi @{name}",
   "account.mute_notifications": "Silentigi sciigojn el @{name}",
   "account.muted": "Silentigita",
-  "account.posts": "Tootoj",
+  "account.posts": "Mesaĝoj",
   "account.posts_with_replies": "Kun respondoj",
   "account.report": "Signali @{name}",
   "account.requested": "Atendo de aprobo. Alklaku por nuligi peton de sekvado",
@@ -29,8 +30,8 @@
   "account.unmute": "Malsilentigi @{name}",
   "account.unmute_notifications": "Malsilentigi sciigojn de @{name}",
   "account.view_full_profile": "Vidi plenan profilon",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Neatendita eraro okazis.",
+  "alert.unexpected.title": "Ups!",
   "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje",
   "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.",
   "bundle_column_error.retry": "Bonvolu reprovi",
@@ -40,8 +41,8 @@
   "bundle_modal_error.retry": "Bonvolu reprovi",
   "column.blocks": "Blokitaj uzantoj",
   "column.community": "Loka tempolinio",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Rektaj mesaĝoj",
+  "column.domain_blocks": "Kaŝitaj domajnoj",
   "column.favourites": "Stelumoj",
   "column.follow_requests": "Petoj de sekvado",
   "column.home": "Hejmo",
@@ -59,7 +60,8 @@
   "column_header.unpin": "Depingli",
   "column_subheading.navigation": "Navigado",
   "column_subheading.settings": "Agordado",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Tiu mesaĝo estos sendita nur al menciitaj uzantoj.",
+  "compose_form.direct_message_warning_learn_more": "Lerni pli",
   "compose_form.hashtag_warning": "Ĉi tiu mesaĝo ne estos listigita per ajna kradvorto. Nur publikaj mesaĝoj estas serĉeblaj per kradvortoj.",
   "compose_form.lock_disclaimer": "Via konta ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn, kiuj estas nur por sekvantoj.",
   "compose_form.lock_disclaimer.lock": "ŝlosita",
@@ -101,7 +103,7 @@
   "emoji_button.symbols": "Simboloj",
   "emoji_button.travel": "Vojaĝoj kaj lokoj",
   "empty_column.community": "La loka tempolinio estas malplena. Skribu ion por plenigi ĝin!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Vi ankoraŭ ne havas rektan mesaĝon. Kiam vi sendos aŭ ricevos iun, ĝi aperos ĉi tie.",
   "empty_column.hashtag": "Ankoraŭ estas nenio per ĉi tiu kradvorto.",
   "empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.",
   "empty_column.home.public_timeline": "la publikan tempolinion",
@@ -135,7 +137,7 @@
   "keyboard_shortcuts.mention": "por mencii la aŭtoron",
   "keyboard_shortcuts.reply": "por respondi",
   "keyboard_shortcuts.search": "por fokusigi la serĉilon",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "por montri/kaŝi tekston malantaŭ enhava averto",
   "keyboard_shortcuts.toot": "por komenci tute novan mesaĝon",
   "keyboard_shortcuts.unfocus": "por malfokusigi la tekstujon aŭ la serĉilon",
   "keyboard_shortcuts.up": "por iri supren en la listo",
@@ -157,8 +159,8 @@
   "mute_modal.hide_notifications": "Ĉu vi volas kaŝi la sciigojn el ĉi tiu uzanto?",
   "navigation_bar.blocks": "Blokitaj uzantoj",
   "navigation_bar.community_timeline": "Loka tempolinio",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Rektaj mesaĝoj",
+  "navigation_bar.domain_blocks": "Kaŝitaj domajnoj",
   "navigation_bar.edit_profile": "Redakti profilon",
   "navigation_bar.favourites": "Stelumoj",
   "navigation_bar.follow_requests": "Petoj de sekvado",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Diskonigoj:",
   "notifications.column_settings.show": "Montri en kolumno",
   "notifications.column_settings.sound": "Eligi sonon",
+  "notifications.group": "{count} sciigoj",
   "onboarding.done": "Farita",
   "onboarding.next": "Sekva",
   "onboarding.page_five.public_timelines": "La loka tempolinio montras publikajn mesaĝojn de ĉiuj en {domain}. La fratara tempolinio montras publikajn mesaĝojn de ĉiuj, kiuj estas sekvataj de homoj en {domain}. Tio estas la publikaj tempolinioj, kio estas bona maniero por malkovri novajn homojn.",
@@ -242,10 +245,10 @@
   "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezultoj}}",
   "standalone.public_title": "Enrigardo…",
   "status.block": "Bloki @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Eksdiskonigi",
   "status.cannot_reblog": "Ĉi tiu mesaĝo ne diskonigeblas",
   "status.delete": "Forigi",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Rekte mesaĝi @{name}",
   "status.embed": "Enkorpigi",
   "status.favourite": "Stelumi",
   "status.load_more": "Ŝargi pli",
@@ -258,7 +261,7 @@
   "status.pin": "Alpingli profile",
   "status.pinned": "Alpinglita mesaĝo",
   "status.reblog": "Diskonigi",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Diskonigi al la originala atentaro",
   "status.reblogged_by": "{name} diskonigis",
   "status.reply": "Respondi",
   "status.replyAll": "Respondi al la fadeno",
@@ -276,13 +279,15 @@
   "tabs_bar.home": "Hejmo",
   "tabs_bar.local_timeline": "Loka tempolinio",
   "tabs_bar.notifications": "Sciigoj",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Serĉi",
+  "timeline.media": "Aŭdovidaĵoj",
+  "timeline.posts": "Mesaĝoj",
   "ui.beforeunload": "Via malneto perdiĝos se vi eliras de Mastodon.",
   "upload_area.title": "Altreni kaj lasi por alŝuti",
   "upload_button.label": "Aldoni aŭdovidaĵon",
   "upload_form.description": "Priskribi por misvidantaj homoj",
   "upload_form.focus": "Stuci",
-  "upload_form.undo": "Malfari",
+  "upload_form.undo": "Forigi",
   "upload_progress.label": "Alŝutado…",
   "video.close": "Fermi videon",
   "video.exit_fullscreen": "Eksigi plenekrana",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 41d7db9da..6092630c4 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Bloquear",
   "account.block_domain": "Ocultar todo de {domain}",
   "account.blocked": "Bloqueado",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navegación",
   "column_subheading.settings": "Ajustes",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Este toot no se mostrará bajo hashtags porque no es público. Sólo los toots públicos se pueden buscar por hashtag.",
   "compose_form.lock_disclaimer": "Tu cuenta no está bloqueada. Todos pueden seguirte para ver tus toots solo para seguidores.",
   "compose_form.lock_disclaimer.lock": "bloqueado",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Retoots:",
   "notifications.column_settings.show": "Mostrar en columna",
   "notifications.column_settings.sound": "Reproducir sonido",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Listo",
   "onboarding.next": "Siguiente",
   "onboarding.page_five.public_timelines": "La línea de tiempo local muestra toots públicos de todos en {domain}. La línea de tiempo federada muestra toots públicos de cualquiera a quien la gente de {domain} siga. Estas son las líneas de tiempo públicas, una buena forma de conocer gente nueva.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificaciones",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Tu borrador se perderá si sales de Mastodon.",
   "upload_area.title": "Arrastra y suelta para subir",
   "upload_button.label": "Subir multimedia",
diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json
index 49cdf5630..636c51f6f 100644
--- a/app/javascript/mastodon/locales/eu.json
+++ b/app/javascript/mastodon/locales/eu.json
@@ -1,8 +1,9 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokeatu @{name}",
-  "account.block_domain": "{domain}(e)ko guztia ezkutatu",
+  "account.block_domain": "Ezkutatu {domain} domeinuko guztia",
   "account.blocked": "Blokeatuta",
-  "account.direct": "@{name}(e)ri mezu zuzena bidali",
+  "account.direct": "Mezu zuzena @{name} erabiltzaileari",
   "account.disclaimer_full": "Baliteke beheko informazioak erabiltzailearen profilaren zati bat baino ez erakustea.",
   "account.domain_blocked": "Ezkutatutako domeinua",
   "account.edit_profile": "Profila aldatu",
@@ -10,287 +11,291 @@
   "account.followers": "Jarraitzaileak",
   "account.follows": "Jarraitzen",
   "account.follows_you": "Jarraitzen dizu",
-  "account.hide_reblogs": "@{name}(e)k sustatutakoak ezkutatu",
+  "account.hide_reblogs": "Ezkutatu @{name} erabiltzailearen bultzadak",
   "account.media": "Media",
   "account.mention": "@{name} aipatu",
   "account.moved_to": "{name} hona lekualdatu da:",
   "account.mute": "@{name} isilarazi",
-  "account.mute_notifications": "@{name}(e)ren jakinarazpenak isilarazi",
+  "account.mute_notifications": "Mututu @{name} erabiltzailearen jakinarazpenak",
   "account.muted": "Isilarazita",
-  "account.posts": "Toots",
-  "account.posts_with_replies": "Toots and replies",
+  "account.posts": "Toot-ak",
+  "account.posts_with_replies": "Toot eta erantzunak",
   "account.report": "@{name} salatu",
   "account.requested": "Onarpenaren zain. Klikatu jarraitzeko eskaera ezeztatzeko",
   "account.share": "@{name}(e)ren profila elkarbanatu",
-  "account.show_reblogs": "@{name}(e)k sustatutakoak erakutsi",
-  "account.unblock": "@{name} desblokeatu",
+  "account.show_reblogs": "Erakutsi @{name} erabiltzailearen bultzadak",
+  "account.unblock": "Desblokeatu @{name}",
   "account.unblock_domain": "Berriz erakutsi {domain}",
   "account.unfollow": "Jarraitzeari utzi",
-  "account.unmute": "Unmute @{name}",
-  "account.unmute_notifications": "Unmute notifications from @{name}",
-  "account.view_full_profile": "View full profile",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
-  "boost_modal.combo": "You can press {combo} to skip this next time",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
+  "account.unmute": "Desmututu @{name}",
+  "account.unmute_notifications": "Desmututu @{name} erabiltzailearen jakinarazpenak",
+  "account.view_full_profile": "Ikusi profil osoa",
+  "alert.unexpected.message": "Ustekabeko errore bat gertatu da.",
+  "alert.unexpected.title": "Ene!",
+  "boost_modal.combo": "{combo} sakatu dezakezu hurrengoan hau saltatzeko",
+  "bundle_column_error.body": "Zerbait okerra gertatu da osagai hau kargatzean.",
+  "bundle_column_error.retry": "Saiatu berriro",
   "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
-  "column.blocks": "Blocked users",
-  "column.community": "Local timeline",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
-  "column.favourites": "Favourites",
-  "column.follow_requests": "Follow requests",
+  "bundle_modal_error.close": "Itxi",
+  "bundle_modal_error.message": "Zerbait okerra gertatu da osagai hau kargatzean.",
+  "bundle_modal_error.retry": "Saiatu berriro",
+  "column.blocks": "Blokeatutako erabiltzaileak",
+  "column.community": "Denbora-lerro lokala",
+  "column.direct": "Mezu zuzenak",
+  "column.domain_blocks": "Domeinu ezkutuak",
+  "column.favourites": "Gogokoak",
+  "column.follow_requests": "Jarraitzeko eskariak",
   "column.home": "Home",
-  "column.lists": "Lists",
-  "column.mutes": "Muted users",
-  "column.notifications": "Notifications",
+  "column.lists": "Zerrendak",
+  "column.mutes": "Mutututako erabiltzaileak",
+  "column.notifications": "Jakinarazpenak",
   "column.pins": "Pinned toot",
-  "column.public": "Federated timeline",
-  "column_back_button.label": "Back",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
-  "column_header.pin": "Pin",
-  "column_header.show_settings": "Show settings",
-  "column_header.unpin": "Unpin",
+  "column.public": "Federatutako denbora-lerroa",
+  "column_back_button.label": "Atzera",
+  "column_header.hide_settings": "Ezkutatu ezarpenak",
+  "column_header.moveLeft_settings": "Eraman zutabea ezkerrera",
+  "column_header.moveRight_settings": "Eraman zutabea eskuinera",
+  "column_header.pin": "Finkatu",
+  "column_header.show_settings": "Erakutsi ezarpenak",
+  "column_header.unpin": "Desfinkatu",
   "column_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
-  "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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
-  "compose_form.lock_disclaimer.lock": "locked",
-  "compose_form.placeholder": "What is on your mind?",
+  "column_subheading.settings": "Ezarpenak",
+  "compose_form.direct_message_warning": "Toot hau aipatutako erabiltzaileei besterik ez zaie bidaliko.",
+  "compose_form.direct_message_warning_learn_more": "Ikasi gehiago",
+  "compose_form.hashtag_warning": "Toot hau ez da traoletan agertuko zerrendatu gabekoa baita. Traoletan toot publikoak besterik ez dira agertzen.",
+  "compose_form.lock_disclaimer": "Zure kontua ez dago {locked}. Edonork jarraitu zaitzake zure jarraitzaileentzako soilik diren mezuak ikusteko.",
+  "compose_form.lock_disclaimer.lock": "blokeatuta",
+  "compose_form.placeholder": "Zer duzu buruan?",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "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",
+  "compose_form.sensitive.marked": "Multimedia mingarri gisa markatu da",
+  "compose_form.sensitive.unmarked": "Multimedia ez da mingarri gisa markatu",
+  "compose_form.spoiler.marked": "Testua abisu batek ezkutatzen du",
+  "compose_form.spoiler.unmarked": "Testua ez dago ezkutatuta",
+  "compose_form.spoiler_placeholder": "Idatzi zure abisua hemen",
+  "confirmation_modal.cancel": "Utzi",
   "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.block.message": "Ziur {name} blokeatu nahi duzula?",
   "confirmations.delete.confirm": "Delete",
   "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.domain_block.confirm": "Hide entire domain",
+  "confirmations.delete_list.message": "Ziur behin betiko ezabatu nahi duzula zerrenda hau?",
+  "confirmations.domain_block.confirm": "Ezkutatu domeinu osoa",
   "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.mute.confirm": "Mute",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.mute.confirm": "Mututu",
+  "confirmations.mute.message": "Ziur {name} mututu nahi duzula?",
+  "confirmations.unfollow.confirm": "Utzi jarraitzeari",
+  "confirmations.unfollow.message": "Ziur {name} jarraitzeari utzi nahi diozula?",
   "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",
+  "embed.preview": "Hau da izango duen itxura:",
+  "emoji_button.activity": "Jarduera",
+  "emoji_button.custom": "Pertsonalizatua",
+  "emoji_button.flags": "Banderak",
+  "emoji_button.food": "Janari eta edaria",
   "emoji_button.label": "Insert emoji",
-  "emoji_button.nature": "Nature",
+  "emoji_button.nature": "Natura",
   "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.recent": "Frequently used",
-  "emoji_button.search": "Search...",
-  "emoji_button.search_results": "Search results",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
+  "emoji_button.objects": "Objektuak",
+  "emoji_button.people": "Jendea",
+  "emoji_button.recent": "Maiz erabiliak",
+  "emoji_button.search": "Bilatu...",
+  "emoji_button.search_results": "Bilaketaren emaitzak",
+  "emoji_button.symbols": "Sinboloak",
+  "emoji_button.travel": "Bidaiak eta tokiak",
   "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
   "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
-  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.hashtag": "Ez dago ezer traola honetan oraindik.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
-  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.home.public_timeline": "denbora-lerro publikoa",
   "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
-  "follow_request.authorize": "Authorize",
-  "follow_request.reject": "Reject",
-  "getting_started.appsshort": "Apps",
+  "follow_request.authorize": "Baimendu",
+  "follow_request.reject": "Ukatu",
+  "getting_started.appsshort": "Aplikazioak",
   "getting_started.faq": "FAQ",
-  "getting_started.heading": "Getting started",
+  "getting_started.heading": "Lehen urratsak",
   "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
-  "getting_started.userguide": "User Guide",
-  "home.column_settings.advanced": "Advanced",
-  "home.column_settings.basic": "Basic",
-  "home.column_settings.filter_regex": "Filter out by regular expressions",
-  "home.column_settings.show_reblogs": "Show boosts",
-  "home.column_settings.show_replies": "Show replies",
-  "home.settings": "Column settings",
-  "keyboard_shortcuts.back": "to navigate back",
-  "keyboard_shortcuts.boost": "to boost",
+  "getting_started.userguide": "Erabiltzaile gida",
+  "home.column_settings.advanced": "Aurreratua",
+  "home.column_settings.basic": "Oinarrizkoa",
+  "home.column_settings.filter_regex": "Iragazi adierazpen erregularren bidez",
+  "home.column_settings.show_reblogs": "Erakutsi bultzadak",
+  "home.column_settings.show_replies": "Erakutsi erantzunak",
+  "home.settings": "Zutabearen ezarpenak",
+  "keyboard_shortcuts.back": "atzera nabigatzeko",
+  "keyboard_shortcuts.boost": "bultzada ematea",
   "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.down": "to move down in the list",
+  "keyboard_shortcuts.down": "zerrendan behera mugitzea",
   "keyboard_shortcuts.enter": "to open status",
-  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.favourite": "gogoko egitea",
   "keyboard_shortcuts.heading": "Keyboard Shortcuts",
-  "keyboard_shortcuts.hotkey": "Hotkey",
-  "keyboard_shortcuts.legend": "to display this legend",
-  "keyboard_shortcuts.mention": "to mention author",
-  "keyboard_shortcuts.reply": "to reply",
-  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.hotkey": "Laster-tekla",
+  "keyboard_shortcuts.legend": "legenda hau bistaratzea",
+  "keyboard_shortcuts.mention": "egilea aipatzea",
+  "keyboard_shortcuts.reply": "erantzutea",
+  "keyboard_shortcuts.search": "bilaketan fokua jartzea",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
-  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.toot": "toot berria hastea",
   "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
-  "keyboard_shortcuts.up": "to move up in the list",
-  "lightbox.close": "Close",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
-  "lists.account.add": "Add to list",
-  "lists.account.remove": "Remove from list",
+  "keyboard_shortcuts.up": "zerrendan gora mugitzea",
+  "lightbox.close": "Itxi",
+  "lightbox.next": "Hurrengoa",
+  "lightbox.previous": "Aurrekoa",
+  "lists.account.add": "Gehitu zerrendara",
+  "lists.account.remove": "Kendu zerrendatik",
   "lists.delete": "Delete list",
-  "lists.edit": "Edit list",
-  "lists.new.create": "Add list",
-  "lists.new.title_placeholder": "New list title",
-  "lists.search": "Search among people you follow",
-  "lists.subheading": "Your lists",
-  "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
-  "missing_indicator.label": "Not found",
-  "missing_indicator.sublabel": "This resource could not be found",
-  "mute_modal.hide_notifications": "Hide notifications from this user?",
-  "navigation_bar.blocks": "Blocked users",
-  "navigation_bar.community_timeline": "Local timeline",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
-  "navigation_bar.edit_profile": "Edit profile",
-  "navigation_bar.favourites": "Favourites",
-  "navigation_bar.follow_requests": "Follow requests",
+  "lists.edit": "Editatu zerrenda",
+  "lists.new.create": "Gehitu zerrenda",
+  "lists.new.title_placeholder": "Zerrenda berriaren izena",
+  "lists.search": "Bilatu jarraitzen dituzun pertsonen artean",
+  "lists.subheading": "Zure zerrendak",
+  "loading_indicator.label": "Kargatzen...",
+  "media_gallery.toggle_visible": "Txandakatu ikusgaitasuna",
+  "missing_indicator.label": "Ez aurkitua",
+  "missing_indicator.sublabel": "Baliabide hau ezin izan da aurkitu",
+  "mute_modal.hide_notifications": "Ezkutatu erabiltzaile honen jakinarazpenak?",
+  "navigation_bar.blocks": "Blokeatutako erabiltzaileak",
+  "navigation_bar.community_timeline": "Denbora-lerro lokala",
+  "navigation_bar.direct": "Mezu zuzenak",
+  "navigation_bar.domain_blocks": "Domeinu ezkutuak",
+  "navigation_bar.edit_profile": "Editatu profila",
+  "navigation_bar.favourites": "Gogokoak",
+  "navigation_bar.follow_requests": "Jarraitzeko eskariak",
   "navigation_bar.info": "Extended information",
-  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
-  "navigation_bar.lists": "Lists",
-  "navigation_bar.logout": "Logout",
-  "navigation_bar.mutes": "Muted users",
-  "navigation_bar.pins": "Pinned toots",
-  "navigation_bar.preferences": "Preferences",
-  "navigation_bar.public_timeline": "Federated timeline",
+  "navigation_bar.keyboard_shortcuts": "Teklatu laster-bideak",
+  "navigation_bar.lists": "Zerrendak",
+  "navigation_bar.logout": "Amaitu saioa",
+  "navigation_bar.mutes": "Mutututako erabiltzaileak",
+  "navigation_bar.pins": "Finkatutako toot-ak",
+  "navigation_bar.preferences": "Hobespenak",
+  "navigation_bar.public_timeline": "Federatutako denbora-lerroa",
   "notification.favourite": "{name} favourited your status",
-  "notification.follow": "{name} followed you",
-  "notification.mention": "{name} mentioned you",
+  "notification.follow": "{name} erabiltzaileak jarraitzen zaitu",
+  "notification.mention": "{name} erabiltzaileak aipatu zaitu",
   "notification.reblog": "{name} boosted your status",
-  "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.follow": "New followers:",
-  "notifications.column_settings.mention": "Mentions:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
-  "notifications.column_settings.reblog": "Boosts:",
-  "notifications.column_settings.show": "Show in column",
-  "notifications.column_settings.sound": "Play sound",
-  "onboarding.done": "Done",
-  "onboarding.next": "Next",
+  "notifications.clear": "Garbitu jakinarazpenak",
+  "notifications.clear_confirmation": "Ziur zure jakinarazpen guztiak behin betirako garbitu nahi dituzula?",
+  "notifications.column_settings.alert": "Mahaigaineko jakinarazpenak",
+  "notifications.column_settings.favourite": "Gogokoak:",
+  "notifications.column_settings.follow": "Jarraitzaile berriak:",
+  "notifications.column_settings.mention": "Aipamenak:",
+  "notifications.column_settings.push": "Push jakinarazpenak",
+  "notifications.column_settings.push_meta": "Gailu hau",
+  "notifications.column_settings.reblog": "Bultzadak:",
+  "notifications.column_settings.show": "Erakutsi zutabean",
+  "notifications.column_settings.sound": "Jo soinua",
+  "notifications.group": "{count} jakinarazpen",
+  "onboarding.done": "Egina",
+  "onboarding.next": "Hurrengoa",
   "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
-  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
-  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_four.home": "Hasierako denbora-lerroak jarraitzen duzun jendearen mezuak erakusten ditu.",
+  "onboarding.page_four.notifications": "Jakinarazpenen  zutabeak besteek zurekin hasitako hartu-emanak erakusten ditu.",
   "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
-  "onboarding.page_one.full_handle": "Your full handle",
-  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
-  "onboarding.page_one.welcome": "Welcome to Mastodon!",
-  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
-  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_one.full_handle": "Zure erabiltzaile-izen osoa",
+  "onboarding.page_one.handle_hint": "Hau da zure lagunei zu aurkitzeko emango zeniena.",
+  "onboarding.page_one.welcome": "Ongi etorri Mastodon-era!",
+  "onboarding.page_six.admin": "Zure instantziaren administratzailea {admin} da.",
+  "onboarding.page_six.almost_done": "Ia eginda...",
   "onboarding.page_six.appetoot": "Bon Appetoot!",
-  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.apps_available": "{apps} eskuragarri daude iOS, Android eta beste plataformetarako.",
   "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
-  "onboarding.page_six.guidelines": "community guidelines",
-  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
-  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_six.guidelines": "komunitatearen gida-lerroak",
+  "onboarding.page_six.read_guidelines": "Irakurri {domain} {guidelines} mesedez!",
+  "onboarding.page_six.various_app": "mugikorrerako aplikazioak",
   "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
   "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
   "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
-  "onboarding.skip": "Skip",
+  "onboarding.skip": "Saltatu",
   "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.direct.long": "Bidali aipatutako erabiltzaileei besterik ez",
+  "privacy.direct.short": "Zuzena",
+  "privacy.private.long": "Bidali jarraitzaileei besterik ez",
+  "privacy.private.short": "Jarraitzaileak soilik",
+  "privacy.public.long": "Bistaratu denbora-lerro publikoetan",
+  "privacy.public.short": "Publikoa",
   "privacy.unlisted.long": "Do not show in public timelines",
-  "privacy.unlisted.short": "Unlisted",
-  "regeneration_indicator.label": "Loading…",
-  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "privacy.unlisted.short": "Zerrendatu gabea",
+  "regeneration_indicator.label": "Kargatzen…",
+  "regeneration_indicator.sublabel": "Zure hasiera-jarioa prestatzen ari da!",
   "relative_time.days": "{number}d",
   "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
+  "relative_time.just_now": "orain",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
-  "reply_indicator.cancel": "Cancel",
-  "report.forward": "Forward to {target}",
+  "reply_indicator.cancel": "Utzi",
+  "report.forward": "Birbidali hona: {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.placeholder": "Iruzkin gehigarriak",
   "report.submit": "Submit",
   "report.target": "Report {target}",
-  "search.placeholder": "Search",
-  "search_popout.search_format": "Advanced search format",
+  "search.placeholder": "Bilatu",
+  "search_popout.search_format": "Bilaketa aurreratuaren formatua",
   "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.hashtag": "traola",
   "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_popout.tips.user": "erabiltzailea",
+  "search_results.accounts": "Jendea",
+  "search_results.hashtags": "Traolak",
+  "search_results.statuses": "Toot-ak",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "Begiradatxo bat...",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
-  "status.cannot_reblog": "This post cannot be boosted",
+  "status.cancel_reblog_private": "Kendu bultzada",
+  "status.cannot_reblog": "Mezu honi ezin zaio bultzada eman",
   "status.delete": "Delete",
-  "status.direct": "Direct message @{name}",
-  "status.embed": "Embed",
-  "status.favourite": "Favourite",
-  "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.direct": "Mezu zuzena @{name} erabiltzaileari",
+  "status.embed": "Txertatu",
+  "status.favourite": "Gogokoa",
+  "status.load_more": "Kargatu gehiago",
+  "status.media_hidden": "Multimedia ezkutatua",
+  "status.mention": "Aipatu @{name}",
+  "status.more": "Gehiago",
+  "status.mute": "Mututu @{name}",
+  "status.mute_conversation": "Mututu elkarrizketa",
   "status.open": "Expand this status",
-  "status.pin": "Pin on profile",
-  "status.pinned": "Pinned toot",
-  "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
-  "status.reblogged_by": "{name} boosted",
-  "status.reply": "Reply",
-  "status.replyAll": "Reply to thread",
+  "status.pin": "Finkatu profilean",
+  "status.pinned": "Finkatutako toot-a",
+  "status.reblog": "Bultzada",
+  "status.reblog_private": "Bultzada jatorrizko hartzaileei",
+  "status.reblogged_by": "{name} erabiltzailearen bultzada",
+  "status.reply": "Erantzun",
+  "status.replyAll": "Erantzun harian",
   "status.report": "Report @{name}",
   "status.sensitive_toggle": "Click to view",
-  "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.unmute_conversation": "Unmute conversation",
-  "status.unpin": "Unpin from profile",
-  "tabs_bar.federated_timeline": "Federated",
+  "status.sensitive_warning": "Eduki mingarria",
+  "status.share": "Partekatu",
+  "status.show_less": "Erakutsi gutxiago",
+  "status.show_less_all": "Erakutsi denetarik gutxiago",
+  "status.show_more": "Erakutsi gehiago",
+  "status.show_more_all": "Erakutsi denetarik gehiago",
+  "status.unmute_conversation": "Desmututu elkarrizketa",
+  "status.unpin": "Desfinkatu profiletik",
+  "tabs_bar.federated_timeline": "Federatua",
   "tabs_bar.home": "Home",
   "tabs_bar.local_timeline": "Local",
-  "tabs_bar.notifications": "Notifications",
-  "tabs_bar.search": "Search",
-  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
-  "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Add media",
-  "upload_form.description": "Describe for the visually impaired",
-  "upload_form.focus": "Crop",
-  "upload_form.undo": "Undo",
-  "upload_progress.label": "Uploading...",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
+  "tabs_bar.notifications": "Jakinarazpenak",
+  "tabs_bar.search": "Bilatu",
+  "timeline.media": "Media",
+  "timeline.posts": "Toot-ak",
+  "ui.beforeunload": "Zure zirriborroa galduko da Mastodon uzten baduzu.",
+  "upload_area.title": "Arrastatu eta jaregin igotzeko",
+  "upload_button.label": "Gehitu multimedia",
+  "upload_form.description": "Deskribatu ikusmen arazoak dituztenentzat",
+  "upload_form.focus": "Moztu",
+  "upload_form.undo": "Ezabatu",
+  "upload_progress.label": "Igotzen...",
+  "video.close": "Itxi bideoa",
+  "video.exit_fullscreen": "Irten pantaila osotik",
+  "video.expand": "Hedatu bideoa",
   "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
+  "video.hide": "Ezkutatu bideoa",
+  "video.mute": "Mututu soinua",
   "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "video.play": "Jo",
+  "video.unmute": "Desmututu soinua"
 }
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 99aba00c3..6c0f34a11 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -1,8 +1,9 @@
 {
+  "account.badges.bot": "ربات",
   "account.block": "مسدودسازی @{name}",
   "account.block_domain": "پنهان‌سازی همه چیز از سرور {domain}",
   "account.blocked": "مسدودشده",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "پیغام خصوصی به @{name}",
   "account.disclaimer_full": "اطلاعات زیر ممکن است نمایهٔ این کاربر را به تمامی نشان ندهد.",
   "account.domain_blocked": "دامین پنهان‌شده",
   "account.edit_profile": "ویرایش نمایه",
@@ -11,7 +12,7 @@
   "account.follows": "پی می‌گیرد",
   "account.follows_you": "پیگیر شماست",
   "account.hide_reblogs": "پنهان کردن بازبوق‌های @{name}",
-  "account.media": "رسانه",
+  "account.media": "عکس و ویدیو",
   "account.mention": "نام‌بردن از @{name}",
   "account.moved_to": "{name} منتقل شده است به:",
   "account.mute": "بی‌صدا کردن @{name}",
@@ -29,8 +30,8 @@
   "account.unmute": "باصدا کردن @{name}",
   "account.unmute_notifications": "باصداکردن اعلان‌ها از طرف @{name}",
   "account.view_full_profile": "نمایش نمایهٔ کامل",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "خطای پیش‌بینی‌نشده‌ای رخ داد.",
+  "alert.unexpected.title": "ای وای!",
   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
   "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.",
   "bundle_column_error.retry": "تلاش دوباره",
@@ -40,8 +41,8 @@
   "bundle_modal_error.retry": "تلاش دوباره",
   "column.blocks": "کاربران مسدودشده",
   "column.community": "نوشته‌های محلی",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "پیغام‌های خصوصی",
+  "column.domain_blocks": "دامین‌های پنهان‌شده",
   "column.favourites": "پسندیده‌ها",
   "column.follow_requests": "درخواست‌های پیگیری",
   "column.home": "خانه",
@@ -59,17 +60,18 @@
   "column_header.unpin": "رهاکردن",
   "column_subheading.navigation": "گشت و گذار",
   "column_subheading.settings": "تنظیمات",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
-  "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.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.placeholder": "تازه چه خبر؟",
   "compose_form.publish": "بوق",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.sensitive.marked": "این تصویر به عنوان حساس علامت‌گذاری شده",
+  "compose_form.sensitive.unmarked": "این تصویر به عنوان حساس علامت‌گذاری نشده",
+  "compose_form.spoiler.marked": "نوشته پشت هشدار محتوا پنهان است",
+  "compose_form.spoiler.unmarked": "نوشته پنهان نیست",
   "compose_form.spoiler_placeholder": "هشدار محتوا",
   "confirmation_modal.cancel": "بی‌خیال",
   "confirmations.block.confirm": "مسدود کن",
@@ -101,7 +103,7 @@
   "emoji_button.symbols": "نمادها",
   "emoji_button.travel": "سفر و مکان",
   "empty_column.community": "فهرست نوشته‌های محلی خالی است. چیزی بنویسید تا چرخش بچرخد!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "شما هیچ پیغام مستقیمی ندارید. اگر چنین پیغامی بگیرید یا بفرستید این‌جا نمایش خواهد یافت.",
   "empty_column.hashtag": "هنوز هیچ چیزی با این هشتگ نیست.",
   "empty_column.home": "شما هنوز پیگیر کسی نیستید. {public} را ببینید یا چیزی را جستجو کنید تا کاربران دیگر را ببینید.",
   "empty_column.home.public_timeline": "فهرست نوشته‌های همه‌جا",
@@ -117,7 +119,7 @@
   "getting_started.userguide": "راهنمای کاربری",
   "home.column_settings.advanced": "پیشرفته",
   "home.column_settings.basic": "اصلی",
-  "home.column_settings.filter_regex": "با عبارت‌های باقاعده فیلتر کنید",
+  "home.column_settings.filter_regex": "با عبارت‌های باقاعده (regexp) فیلتر کنید",
   "home.column_settings.show_reblogs": "نمایش بازبوق‌ها",
   "home.column_settings.show_replies": "نمایش پاسخ‌ها",
   "home.settings": "تنظیمات ستون",
@@ -125,17 +127,17 @@
   "keyboard_shortcuts.boost": "برای بازبوقیدن",
   "keyboard_shortcuts.column": "برای برجسته‌کردن یک نوشته در یکی از ستون‌ها",
   "keyboard_shortcuts.compose": "برای فعال‌کردن کادر نوشتهٔ تازه",
-  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.description": "توضیح",
   "keyboard_shortcuts.down": "برای پایین‌رفتن در فهرست",
-  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.enter": "برای گشودن نوشته",
   "keyboard_shortcuts.favourite": "برای پسندیدن",
-  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.heading": "میان‌برهای صفحه‌کلید",
   "keyboard_shortcuts.hotkey": "میان‌بر",
   "keyboard_shortcuts.legend": "برای نمایش این راهنما",
   "keyboard_shortcuts.mention": "برای نام‌بردن از نویسنده",
   "keyboard_shortcuts.reply": "برای پاسخ‌دادن",
   "keyboard_shortcuts.search": "برای فعال‌کردن جستجو",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "برای نمایش/نهفتن نوشتهٔ پشت هشدار محتوا",
   "keyboard_shortcuts.toot": "برای آغاز یک بوق تازه",
   "keyboard_shortcuts.unfocus": "برای برداشتن توجه از نوشتن/جستجو",
   "keyboard_shortcuts.up": "برای بالا رفتن در فهرست",
@@ -153,12 +155,12 @@
   "loading_indicator.label": "بارگیری...",
   "media_gallery.toggle_visible": "تغییر پیدایی",
   "missing_indicator.label": "پیدا نشد",
-  "missing_indicator.sublabel": "This resource could not be found",
+  "missing_indicator.sublabel": "این منبع پیدا نشد",
   "mute_modal.hide_notifications": "اعلان‌های این کاربر پنهان شود؟",
   "navigation_bar.blocks": "کاربران مسدودشده",
   "navigation_bar.community_timeline": "نوشته‌های محلی",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "پیغام‌های خصوصی",
+  "navigation_bar.domain_blocks": "دامین‌های پنهان‌شده",
   "navigation_bar.edit_profile": "ویرایش نمایه",
   "navigation_bar.favourites": "پسندیده‌ها",
   "navigation_bar.follow_requests": "درخواست‌های پیگیری",
@@ -185,14 +187,15 @@
   "notifications.column_settings.reblog": "بازبوق‌ها:",
   "notifications.column_settings.show": "نمایش در ستون",
   "notifications.column_settings.sound": "پخش صدا",
+  "notifications.group": "{count} اعلان",
   "onboarding.done": "پایان",
   "onboarding.next": "بعدی",
   "onboarding.page_five.public_timelines": "نوشته‌های محلی یعنی نوشته‌های همهٔ کاربران {domain}. نوشته‌های همه‌جا یعنی نوشته‌های همهٔ کسانی که کاربران {domain} آن‌ها را پی می‌گیرند. این فهرست‌های عمومی راه خوبی برای یافتن کاربران تازه هستند.",
   "onboarding.page_four.home": "ستون «خانه» نوشته‌های کسانی را نشان می‌دهد که شما پی می‌گیرید.",
   "onboarding.page_four.notifications": "ستون «اعلان‌ها» ارتباط‌های شما با دیگران را نشان می‌دهد.",
   "onboarding.page_one.federation": "ماستدون شبکه‌ای از سرورهای مستقل است که با پیوستن به یکدیگر یک شبکهٔ اجتماعی بزرگ را تشکیل می‌دهند.",
-  "onboarding.page_one.full_handle": "Your full handle",
-  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
+  "onboarding.page_one.full_handle": "شناسهٔ کاربری کامل شما",
+  "onboarding.page_one.handle_hint": "این چیزی است که باید به دوستان خود بگویید تا بتوانند شما را پیدا کنند.",
   "onboarding.page_one.welcome": "به ماستدون خوش آمدید!",
   "onboarding.page_six.admin": "نشانی مسئول سرور شما {admin} است.",
   "onboarding.page_six.almost_done": "الان تقریباً آماده‌اید...",
@@ -211,54 +214,54 @@
   "privacy.direct.short": "مستقیم",
   "privacy.private.long": "تنها به پیگیران نشان بده",
   "privacy.private.short": "خصوصی",
-  "privacy.public.long": "در فهرست عمومی نشان بده",
+  "privacy.public.long": "نمایش در فهرست عمومی",
   "privacy.public.short": "عمومی",
   "privacy.unlisted.long": "عمومی، ولی فهرست نکن",
   "privacy.unlisted.short": "فهرست‌نشده",
-  "regeneration_indicator.label": "Loading…",
-  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "regeneration_indicator.label": "در حال باز شدن…",
+  "regeneration_indicator.sublabel": "این فهرست دارد آماده می‌شود!",
   "relative_time.days": "{number}d",
   "relative_time.hours": "{number}h",
   "relative_time.just_now": "الان",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
   "reply_indicator.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.forward": "فرستادن به {target}",
+  "report.forward_hint": "این حساب در سرور دیگری ثبت شده. آیا می‌خواهید رونوشتی از این گزارش به طور ناشناس به آن‌جا هم فرستاده شود؟",
+  "report.hint": "این گزارش به مدیران سرور شما فرستاده خواهد شد. می‌توانید دلیل گزارش‌دادن این حساب را در این‌جا بنویسید:",
   "report.placeholder": "توضیح اضافه",
   "report.submit": "بفرست",
   "report.target": "گزارش‌دادن",
   "search.placeholder": "جستجو",
   "search_popout.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.full_text": "جستجوی متنی ساده می‌تواند بوق‌هایی که شما نوشته‌اید، پسندیده‌اید، بازبوقیده‌اید، یا در آن‌ها از شما نام برده شده است را پیدا کند. همچنین نام‌های کاربری، نام نمایش‌یافته، و هشتگ‌ها را هم شامل می‌شود.",
   "search_popout.tips.hashtag": "هشتگ",
   "search_popout.tips.status": "نوشته",
   "search_popout.tips.text": "جستجوی متنی ساده برای نام‌ها، نام‌های کاربری، و هشتگ‌ها",
   "search_popout.tips.user": "کاربر",
-  "search_results.accounts": "People",
-  "search_results.hashtags": "Hashtags",
-  "search_results.statuses": "Toots",
+  "search_results.accounts": "افراد",
+  "search_results.hashtags": "هشتگ‌ها",
+  "search_results.statuses": "بوق‌ها",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
   "standalone.public_title": "نگاهی به کاربران این سرور...",
-  "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.block": "مسدودسازی @{name}",
+  "status.cancel_reblog_private": "حذف بازبوق",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
   "status.delete": "پاک‌کردن",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "پیغام مستقیم به @{name}",
   "status.embed": "جاگذاری",
   "status.favourite": "پسندیدن",
   "status.load_more": "بیشتر نشان بده",
   "status.media_hidden": "تصویر پنهان شده",
   "status.mention": "نام‌بردن از @{name}",
   "status.more": "بیشتر",
-  "status.mute": "Mute @{name}",
+  "status.mute": "بی‌صدا کردن @{name}",
   "status.mute_conversation": "بی‌صداکردن گفتگو",
   "status.open": "این نوشته را باز کن",
   "status.pin": "نوشتهٔ ثابت نمایه",
-  "status.pinned": "Pinned toot",
+  "status.pinned": "بوق ثابت",
   "status.reblog": "بازبوقیدن",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "بازبوق به مخاطبان اولیه",
   "status.reblogged_by": "‫{name}‬ بازبوقید",
   "status.reply": "پاسخ",
   "status.replyAll": "به نوشته پاسخ دهید",
@@ -267,22 +270,24 @@
   "status.sensitive_warning": "محتوای حساس",
   "status.share": "هم‌رسانی",
   "status.show_less": "نهفتن",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "نمایش کمتر همه",
   "status.show_more": "نمایش",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "نمایش بیشتر همه",
   "status.unmute_conversation": "باصداکردن گفتگو",
   "status.unpin": "برداشتن نوشتهٔ ثابت نمایه",
   "tabs_bar.federated_timeline": "همگانی",
   "tabs_bar.home": "خانه",
   "tabs_bar.local_timeline": "محلی",
   "tabs_bar.notifications": "اعلان‌ها",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "جستجو",
+  "timeline.media": "عکس و ویدیو",
+  "timeline.posts": "بوق‌ها",
   "ui.beforeunload": "اگر از ماستدون خارج شوید پیش‌نویس شما پاک خواهد شد.",
   "upload_area.title": "برای بارگذاری به این‌جا بکشید",
   "upload_button.label": "افزودن تصویر",
   "upload_form.description": "نوشتهٔ توضیحی برای کم‌بینایان و نابینایان",
-  "upload_form.focus": "Crop",
-  "upload_form.undo": "واگردانی",
+  "upload_form.focus": "بریدن لبه‌ها",
+  "upload_form.undo": "حذف",
   "upload_progress.label": "بارگذاری...",
   "video.close": "بستن ویدیو",
   "video.exit_fullscreen": "خروج از حالت تمام صفحه",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 07d4d9aa5..d04e0230a 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Estä @{name}",
   "account.block_domain": "Piilota kaikki sisältö verkkotunnuksesta {domain}",
   "account.blocked": "Estetty",
@@ -29,8 +30,8 @@
   "account.unmute": "Poista käyttäjän @{name} mykistys",
   "account.unmute_notifications": "Poista mykistys käyttäjän @{name} ilmoituksilta",
   "account.view_full_profile": "Näytä koko profiili",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Tapahtui odottamaton virhe.",
+  "alert.unexpected.title": "Hups!",
   "boost_modal.combo": "Ensi kerralla voit ohittaa tämän painamalla {combo}",
   "bundle_column_error.body": "Jokin meni vikaan komponenttia ladattaessa.",
   "bundle_column_error.retry": "Yritä uudestaan",
@@ -41,7 +42,7 @@
   "column.blocks": "Estetyt käyttäjät",
   "column.community": "Paikallinen aikajana",
   "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.domain_blocks": "Piilotetut verkkotunnukset",
   "column.favourites": "Suosikit",
   "column.follow_requests": "Seuraamispyynnöt",
   "column.home": "Koti",
@@ -59,7 +60,8 @@
   "column_header.unpin": "Poista kiinnitys",
   "column_subheading.navigation": "Navigaatio",
   "column_subheading.settings": "Asetukset",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Tämä tuuttaus näkyy vain mainituille käyttäjille.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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",
@@ -135,7 +137,7 @@
   "keyboard_shortcuts.mention": "mainitse julkaisija",
   "keyboard_shortcuts.reply": "vastaa",
   "keyboard_shortcuts.search": "siirry hakukenttään",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "näytä/piilota sisältövaroituksella merkitty teksti",
   "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",
@@ -158,7 +160,7 @@
   "navigation_bar.blocks": "Estetyt käyttäjät",
   "navigation_bar.community_timeline": "Paikallinen aikajana",
   "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.domain_blocks": "Piilotetut verkkotunnukset",
   "navigation_bar.edit_profile": "Muokkaa profiilia",
   "navigation_bar.favourites": "Suosikit",
   "navigation_bar.follow_requests": "Seuraamispyynnöt",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Buustit:",
   "notifications.column_settings.show": "Näytä sarakkeessa",
   "notifications.column_settings.sound": "Äänimerkki",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Valmis",
   "onboarding.next": "Seuraava",
   "onboarding.page_five.public_timelines": "Paikallisella aikajanalla näytetään instanssin {domain} kaikkien käyttäjien julkiset julkaisut. Yleisellä aikajanalla näytetään kaikkien instanssin {domain} käyttäjien seuraamien käyttäjien julkiset julkaisut. Nämä julkiset aikajanat ovat loistavia paikkoja löytää uusia ihmisiä.",
@@ -242,10 +245,10 @@
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "Kurkistus sisälle...",
   "status.block": "Estä @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Peru buustaus",
   "status.cannot_reblog": "Tätä julkaisua ei voi buustata",
   "status.delete": "Poista",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Viesti käyttäjälle @{name}",
   "status.embed": "Upota",
   "status.favourite": "Tykkää",
   "status.load_more": "Lataa lisää",
@@ -258,7 +261,7 @@
   "status.pin": "Kiinnitä profiiliin",
   "status.pinned": "Kiinnitetty tuuttaus",
   "status.reblog": "Buustaa",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Buustaa alkuperäiselle yleisölle",
   "status.reblogged_by": "{name} buustasi",
   "status.reply": "Vastaa",
   "status.replyAll": "Vastaa ketjuun",
@@ -276,7 +279,9 @@
   "tabs_bar.home": "Koti",
   "tabs_bar.local_timeline": "Paikallinen",
   "tabs_bar.notifications": "Ilmoitukset",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Hae",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Luonnos häviää, jos poistut Mastodonista.",
   "upload_area.title": "Lataa raahaamalla ja pudottamalla tähän",
   "upload_button.label": "Lisää mediaa",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index a4af97dda..24b771efa 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -1,8 +1,9 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Bloquer @{name}",
   "account.block_domain": "Tout masquer venant de {domain}",
   "account.blocked": "Bloqué",
-  "account.direct": "Message direct @{name}",
+  "account.direct": "Message direct à @{name}",
   "account.disclaimer_full": "Les données ci-dessous peuvent ne pas refléter ce profil dans sa totalité.",
   "account.domain_blocked": "Domaine caché",
   "account.edit_profile": "Modifier le profil",
@@ -13,7 +14,7 @@
   "account.hide_reblogs": "Masquer les partages de @{name}",
   "account.media": "Média",
   "account.mention": "Mentionner",
-  "account.moved_to": "{name} a déménagé vers :",
+  "account.moved_to": "{name} a déménagé vers :",
   "account.mute": "Masquer @{name}",
   "account.mute_notifications": "Ignorer les notifications de @{name}",
   "account.muted": "Silencé",
@@ -30,7 +31,7 @@
   "account.unmute_notifications": "Réactiver les notifications de @{name}",
   "account.view_full_profile": "Afficher le profil complet",
   "alert.unexpected.message": "Une erreur non-attendue s'est produite.",
-  "alert.unexpected.title": "Oups !",
+  "alert.unexpected.title": "Oups !",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois",
   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
   "bundle_column_error.retry": "Réessayer",
@@ -59,7 +60,8 @@
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
-  "compose_form.direct_message_warning": "Ce pouet sera uniquement visible à tous les utilisateurs mentionnés.",
+  "compose_form.direct_message_warning": "Ce pouet sera uniquement envoyé qu'aux personnes mentionnées. Cependant, l'administration de votre instance et des instances réceptrices pourront inspecter ce message.",
+  "compose_form.direct_message_warning_learn_more": "En savoir plus",
   "compose_form.hashtag_warning": "Ce pouet ne sera pas listé dans les recherches par hashtag car sa visibilité est réglée sur \"non-listé\". Seuls les pouets avec une visibilité \"publique\" peuvent être recherchés par hashtag.",
   "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.",
   "compose_form.lock_disclaimer.lock": "verrouillé",
@@ -77,7 +79,7 @@
   "confirmations.delete.confirm": "Supprimer",
   "confirmations.delete.message": "Confirmez-vous la suppression de ce pouet ?",
   "confirmations.delete_list.confirm": "Supprimer",
-  "confirmations.delete_list.message": "Êtes-vous sûr de vouloir supprimer définitivement cette liste ?",
+  "confirmations.delete_list.message": "Êtes-vous sûr de vouloir supprimer définitivement cette liste ?",
   "confirmations.domain_block.confirm": "Masquer le domaine entier",
   "confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables.",
   "confirmations.mute.confirm": "Masquer",
@@ -85,14 +87,14 @@
   "confirmations.unfollow.confirm": "Ne plus suivre",
   "confirmations.unfollow.message": "Voulez-vous arrêter de suivre {name} ?",
   "embed.instructions": "Intégrez ce statut à votre site en copiant le code ci-dessous.",
-  "embed.preview": "Il apparaîtra comme cela :",
+  "embed.preview": "Il apparaîtra comme cela :",
   "emoji_button.activity": "Activités",
   "emoji_button.custom": "Personnalisés",
   "emoji_button.flags": "Drapeaux",
   "emoji_button.food": "Nourriture & Boisson",
   "emoji_button.label": "Insérer un émoji",
   "emoji_button.nature": "Nature",
-  "emoji_button.not_found": "Pas d'emojis !! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Pas d'emojis !! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objets",
   "emoji_button.people": "Personnages",
   "emoji_button.recent": "Fréquemment utilisés",
@@ -103,7 +105,7 @@
   "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.",
   "empty_column.hashtag": "Il n’y a encore aucun contenu associé à ce hashtag.",
-  "empty_column.home": "Vous ne suivez encore personne. Visitez {public} ou bien utilisez la recherche pour vous connecter à d’autres personnes.",
+  "empty_column.home": "Vous ne suivez personne. Visitez {public} ou utilisez la recherche pour trouver d’autres personnes à suivre.",
   "empty_column.home.public_timeline": "le fil public",
   "empty_column.list": "Il n'y a rien dans cette liste pour l'instant. Dès que des personnes de cette liste publierons de nouveaux statuts, ils apparaîtront ici.",
   "empty_column.notifications": "Vous n’avez pas encore de notification. Interagissez avec d’autres personnes pour débuter la conversation.",
@@ -154,7 +156,7 @@
   "media_gallery.toggle_visible": "Modifier la visibilité",
   "missing_indicator.label": "Non trouvé",
   "missing_indicator.sublabel": "Ressource introuvable",
-  "mute_modal.hide_notifications": "Masquer les notifications de cette personne ?",
+  "mute_modal.hide_notifications": "Masquer les notifications de cette personne ?",
   "navigation_bar.blocks": "Comptes bloqués",
   "navigation_bar.community_timeline": "Fil public local",
   "navigation_bar.direct": "Messages directs",
@@ -177,18 +179,19 @@
   "notifications.clear": "Nettoyer les notifications",
   "notifications.clear_confirmation": "Voulez-vous vraiment supprimer toutes vos notifications ?",
   "notifications.column_settings.alert": "Notifications locales",
-  "notifications.column_settings.favourite": "Favoris :",
-  "notifications.column_settings.follow": "Nouveaux⋅elles abonné⋅e·s :",
-  "notifications.column_settings.mention": "Mentions :",
-  "notifications.column_settings.push": "Notifications push",
+  "notifications.column_settings.favourite": "Favoris :",
+  "notifications.column_settings.follow": "Nouveaux⋅elles abonné⋅e·s :",
+  "notifications.column_settings.mention": "Mentions :",
+  "notifications.column_settings.push": "Notifications",
   "notifications.column_settings.push_meta": "Cet appareil",
   "notifications.column_settings.reblog": "Partages :",
   "notifications.column_settings.show": "Afficher dans la colonne",
   "notifications.column_settings.sound": "Émettre un son",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Effectué",
   "onboarding.next": "Suivant",
-  "onboarding.page_five.public_timelines": "Le fil public global affiche les messages de toutes les personnes suivies par les membres de {domain}. Le fil public local est identique mais se limite aux membres de {domain}.",
-  "onboarding.page_four.home": "L’Accueil affiche les messages des personnes que vous suivez.",
+  "onboarding.page_five.public_timelines": "Le fil public global affiche les messages de toutes les personnes suivies par les membres de {domain}. Le fil public local est identique, mais se limite aux membres de {domain}.",
+  "onboarding.page_four.home": "L’accueil affiche les messages des personnes que vous suivez.",
   "onboarding.page_four.notifications": "La colonne de notification vous avertit lors d'une interaction avec vous.",
   "onboarding.page_one.federation": "Mastodon est un réseau de serveurs indépendants qui se joignent pour former un réseau social plus vaste. Nous appelons ces serveurs des instances.",
   "onboarding.page_one.full_handle": "Votre identifiant complet",
@@ -216,16 +219,16 @@
   "privacy.unlisted.long": "Ne pas afficher dans les fils publics",
   "privacy.unlisted.short": "Non-listé",
   "regeneration_indicator.label": "Chargement…",
-  "regeneration_indicator.sublabel": "Le flux de votre page principale est en cours de préparation !",
-  "relative_time.days": "{number} j",
-  "relative_time.hours": "{number} h",
+  "regeneration_indicator.sublabel": "Le flux de votre page principale est en cours de préparation !",
+  "relative_time.days": "{number} j",
+  "relative_time.hours": "{number} h",
   "relative_time.just_now": "à l’instant",
   "relative_time.minutes": "{number} min",
   "relative_time.seconds": "{number} s",
   "reply_indicator.cancel": "Annuler",
   "report.forward": "Transférer à {target}",
-  "report.forward_hint": "Le compte provient d'un autre serveur. Envoyez également une copie anonyme du rapport ?",
-  "report.hint": "Le rapport sera envoyé aux modérateurs de votre instance. Vous pouvez expliquer pourquoi vous signalez ce compte ci-dessous :",
+  "report.forward_hint": "Le compte provient d'un autre serveur. Envoyez également une copie anonyme du rapport ?",
+  "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",
@@ -236,7 +239,7 @@
   "search_popout.tips.status": "statuts",
   "search_popout.tips.text": "Un texte simple renvoie les noms affichés, les identifiants et les hashtags correspondants",
   "search_popout.tips.user": "utilisateur⋅ice",
-  "search_results.accounts": "Personnes",
+  "search_results.accounts": "Comptes",
   "search_results.hashtags": "Hashtags",
   "search_results.statuses": "Pouets",
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
@@ -245,7 +248,7 @@
   "status.cancel_reblog_private": "Dé-booster",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
   "status.delete": "Effacer",
-  "status.direct": "Message direct @{name}",
+  "status.direct": "Message direct à @{name}",
   "status.embed": "Intégrer",
   "status.favourite": "Ajouter aux favoris",
   "status.load_more": "Charger plus",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "Fil public local",
   "tabs_bar.notifications": "Notifications",
   "tabs_bar.search": "Chercher",
+  "timeline.media": "Media",
+  "timeline.posts": "Pouets",
   "ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.",
   "upload_area.title": "Glissez et déposez pour envoyer",
   "upload_button.label": "Joindre un média",
   "upload_form.description": "Décrire pour les malvoyants",
   "upload_form.focus": "Recadrer",
-  "upload_form.undo": "Annuler",
+  "upload_form.undo": "Supprimer",
   "upload_progress.label": "Envoi en cours…",
   "video.close": "Fermer la vidéo",
   "video.exit_fullscreen": "Quitter plein écran",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 652ca31d1..f2f0fc260 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -1,10 +1,11 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Ocultar calquer contido de {domain}",
-  "account.blocked": "Blocked",
-  "account.direct": "Direct message @{name}",
+  "account.blocked": "Bloqueada",
+  "account.direct": "Mensaxe directa @{name}",
   "account.disclaimer_full": "A información inferior podería mostrar un perfil incompleto da usuaria.",
-  "account.domain_blocked": "Domain hidden",
+  "account.domain_blocked": "Dominio agochado",
   "account.edit_profile": "Editar perfil",
   "account.follow": "Seguir",
   "account.followers": "Seguidoras",
@@ -16,7 +17,7 @@
   "account.moved_to": "{name} marchou a:",
   "account.mute": "Acalar @{name}",
   "account.mute_notifications": "Acalar as notificacións de @{name}",
-  "account.muted": "Muted",
+  "account.muted": "Acalada",
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots e respostas",
   "account.report": "Informar sobre @{name}",
@@ -29,8 +30,8 @@
   "account.unmute": "Non acalar @{name}",
   "account.unmute_notifications": "Desbloquear as notificacións de @{name}",
   "account.view_full_profile": "Ver o perfil completo",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Aconteceu un fallo non agardado.",
+  "alert.unexpected.title": "Vaia!",
   "boost_modal.combo": "Pulse {combo} para saltar esto a próxima vez",
   "bundle_column_error.body": "Houbo un fallo mentras se cargaba este compoñente.",
   "bundle_column_error.retry": "Inténteo de novo",
@@ -40,8 +41,8 @@
   "bundle_modal_error.retry": "Inténteo de novo",
   "column.blocks": "Usuarias bloqueadas",
   "column.community": "Liña temporal local",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Mensaxes directas",
+  "column.domain_blocks": "Dominios agochados",
   "column.favourites": "Favoritas",
   "column.follow_requests": "Peticións de seguimento",
   "column.home": "Inicio",
@@ -59,17 +60,18 @@
   "column_header.unpin": "Soltar",
   "column_subheading.navigation": "Navegación",
   "column_subheading.settings": "Axustes",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Este toot enviarase só as usuarias mencionadas. Porén, a súa proveedora de internet e calquera das instancias receptoras poderían examinar esta mensaxe.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Esta mensaxe non será listada baixo ningunha etiqueta xa que está marcada como non listada. Só os toots públicos poden buscarse por etiquetas.",
   "compose_form.lock_disclaimer": "A súa conta non está {locked}. Calquera pode seguila para ver as súas mensaxes só-para-seguidoras.",
   "compose_form.lock_disclaimer.lock": "bloqueado",
   "compose_form.placeholder": "A qué andas?",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.sensitive.marked": "Medios marcados como sensibles",
+  "compose_form.sensitive.unmarked": "Os medios non están marcados como sensibles",
+  "compose_form.spoiler.marked": "O texto está agochado tras un aviso",
+  "compose_form.spoiler.unmarked": "O texto non está agochado",
   "compose_form.spoiler_placeholder": "Escriba o aviso aquí",
   "confirmation_modal.cancel": "Cancelar",
   "confirmations.block.confirm": "Bloquear",
@@ -101,7 +103,7 @@
   "emoji_button.symbols": "Símbolos",
   "emoji_button.travel": "Viaxes e Lugares",
   "empty_column.community": "A liña temporal local está baldeira. Escriba algo de xeito público para que rule!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Aínda non ten mensaxes directas. Cando envíe ou reciba unha, aparecerá aquí.",
   "empty_column.hashtag": "Aínda non hai nada con esta etiqueta.",
   "empty_column.home": "A súa liña temporal de inicio está baldeira! Visite {public} ou utilice a busca para atopar outras usuarias.",
   "empty_column.home.public_timeline": "a liña temporal pública",
@@ -135,7 +137,7 @@
   "keyboard_shortcuts.mention": "para mencionar o autor",
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.search": "para centrar a busca",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "mostrar/agochar un texto detrás do AC",
   "keyboard_shortcuts.toot": "escribir un toot novo",
   "keyboard_shortcuts.unfocus": "quitar o foco do área de escritura/busca",
   "keyboard_shortcuts.up": "ir hacia arriba na lista",
@@ -153,12 +155,12 @@
   "loading_indicator.label": "Cargando...",
   "media_gallery.toggle_visible": "Ocultar",
   "missing_indicator.label": "Non atopado",
-  "missing_indicator.sublabel": "This resource could not be found",
+  "missing_indicator.sublabel": "Non se puido atopar o recurso",
   "mute_modal.hide_notifications": "Esconder notificacións deste usuario?",
   "navigation_bar.blocks": "Usuarias bloqueadas",
   "navigation_bar.community_timeline": "Liña temporal local",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Mensaxes directas",
+  "navigation_bar.domain_blocks": "Dominios agochados",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favoritas",
   "navigation_bar.follow_requests": "Peticións de seguimento",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Promocións:",
   "notifications.column_settings.show": "Mostrar en columna",
   "notifications.column_settings.sound": "Reproducir son",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Feito",
   "onboarding.next": "Seguinte",
   "onboarding.page_five.public_timelines": "A liña de tempo local mostra as publicacións públicas de todos en {domain}. A liña de tempo federada mostra as publicacións públicas de todos os que as persoas en {domain} seguen. Estas son as Liñas de tempo públicas, unha boa forma de descubrir novas persoas.",
@@ -215,37 +218,37 @@
   "privacy.public.short": "Pública",
   "privacy.unlisted.long": "Non publicar en liñas temporais públicas",
   "privacy.unlisted.short": "Non listada",
-  "regeneration_indicator.label": "Loading…",
-  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "regeneration_indicator.label": "Cargando…",
+  "regeneration_indicator.sublabel": "Estase a preparar a súa liña temporal de inicio!",
   "relative_time.days": "{number}d",
   "relative_time.hours": "{number}h",
   "relative_time.just_now": "agora",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
   "reply_indicator.cancel": "Cancelar",
-  "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.forward": "Reenviar a {target}",
+  "report.forward_hint": "A conta pertence a outro servidor. Enviar unha copia anónima do informe alí tamén?",
+  "report.hint": "O informe enviarase a moderación da súa instancia. Abaixo pode explicar a razón pola que está a información:",
   "report.placeholder": "Comentarios adicionais",
   "report.submit": "Enviar",
   "report.target": "Informar {target}",
   "search.placeholder": "Buscar",
   "search_popout.search_format": "Formato de busca avanzada",
-  "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.full_text": "Texto simple devolve estados que vostede escribeu, promoveu, marcou favoritos, ou foi mencionada, así como nomes de usuaria coincidentes, nomes públicos e etiquetas.",
   "search_popout.tips.hashtag": "etiqueta",
   "search_popout.tips.status": "estado",
   "search_popout.tips.text": "Texto simple devolve coincidencias con nomes públicos, nomes de usuaria e etiquetas",
   "search_popout.tips.user": "usuaria",
-  "search_results.accounts": "People",
-  "search_results.hashtags": "Hashtags",
+  "search_results.accounts": "Xente",
+  "search_results.hashtags": "Etiquetas",
   "search_results.statuses": "Toots",
   "search_results.total": "{count, number} {count,plural,one {result} outros {results}}",
   "standalone.public_title": "Ollada dentro...",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Non promover",
   "status.cannot_reblog": "Esta mensaxe non pode ser promovida",
   "status.delete": "Eliminar",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Mensaxe directa @{name}",
   "status.embed": "Incrustar",
   "status.favourite": "Favorita",
   "status.load_more": "Cargar máis",
@@ -256,9 +259,9 @@
   "status.mute_conversation": "Acalar conversa",
   "status.open": "Expandir este estado",
   "status.pin": "Fixar no perfil",
-  "status.pinned": "Pinned toot",
+  "status.pinned": "Toot fixado",
   "status.reblog": "Promover",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Promover a audiencia orixinal",
   "status.reblogged_by": "{name} promoveu",
   "status.reply": "Resposta",
   "status.replyAll": "Resposta a conversa",
@@ -267,22 +270,24 @@
   "status.sensitive_warning": "Contido sensible",
   "status.share": "Compartir",
   "status.show_less": "Mostrar menos",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "Mostrar menos para todas",
   "status.show_more": "Mostrar máis",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "Mostrar máis para todas",
   "status.unmute_conversation": "Non acalar a conversa",
   "status.unpin": "Despegar do perfil",
   "tabs_bar.federated_timeline": "Federado",
   "tabs_bar.home": "Inicio",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificacións",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Buscar",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "O borrador perderase se sae de Mastodon.",
   "upload_area.title": "Arrastre e solte para subir",
   "upload_button.label": "Engadir medios",
   "upload_form.description": "Describa para deficientes visuais",
-  "upload_form.focus": "Crop",
-  "upload_form.undo": "Desfacer",
+  "upload_form.focus": "Recortar",
+  "upload_form.undo": "Eliminar",
   "upload_progress.label": "Subindo...",
   "video.close": "Pechar video",
   "video.exit_fullscreen": "Saír da pantalla completa",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 0ffbb14f3..361beebcc 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "חסימת @{name}",
   "account.block_domain": "להסתיר הכל מהקהילה {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "ניווט",
   "column_subheading.settings": "אפשרויות",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.",
   "compose_form.lock_disclaimer.lock": "נעול",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "הדהודים:",
   "notifications.column_settings.show": "הצגה בטור",
   "notifications.column_settings.sound": "שמע מופעל",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "יציאה",
   "onboarding.next": "הלאה",
   "onboarding.page_five.public_timelines": "ציר הזמן המקומי מראה הודעות פומביות מכל באי קהילת {domain}. ציר הזמן העולמי מראה הודעות פומביות מאת כי מי שבאי קהילת {domain} עוקבים אחריו. אלו צירי הזמן הפומביים, דרך נהדרת לגלות אנשים חדשים.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "ציר זמן מקומי",
   "tabs_bar.notifications": "התראות",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "הטיוטא תאבד אם תעזבו את מסטודון.",
   "upload_area.title": "ניתן להעלות על ידי Drag & drop",
   "upload_button.label": "הוספת מדיה",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index c41cc3ea1..93dc1c17d 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokiraj @{name}",
   "account.block_domain": "Sakrij sve sa {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigacija",
   "column_subheading.settings": "Postavke",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Tvoj račun nije {locked}. Svatko te može slijediti kako bi vidio postove namijenjene samo tvojim sljedbenicima.",
   "compose_form.lock_disclaimer.lock": "zaključan",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Boostovi:",
   "notifications.column_settings.show": "Prikaži u stupcu",
   "notifications.column_settings.sound": "Sviraj zvuk",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Učinjeno",
   "onboarding.next": "Sljedeće",
   "onboarding.page_five.public_timelines": "Lokalni timeline prikazuje javne postove sviju od svakog na {domain}. Federalni timeline prikazuje javne postove svakog koga ljudi na {domain} slijede. To su Javni Timelineovi, sjajan način za otkriti nove ljude.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Lokalno",
   "tabs_bar.notifications": "Notifikacije",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Povuci i spusti kako bi uploadao",
   "upload_button.label": "Dodaj media",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index a0c186184..158c0fe41 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "@{name} letiltása",
   "account.block_domain": "Minden elrejtése innen: {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigáció",
   "column_subheading.settings": "Beállítások",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Ezen tülkölés nem fog megjelenni semmilyen hashtag alatt mivel listázatlan. Csak a publikus tülkölések kereshetőek hashtag-el.",
   "compose_form.lock_disclaimer": "Az ön fiókja nincs {locked}. Bárki követni tud, hogy megtekintse a kizárt követőknek szánt üzeneteid.",
   "compose_form.lock_disclaimer.lock": "lezárva",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Rebloggolások:",
   "notifications.column_settings.show": "Oszlopban mutatás",
   "notifications.column_settings.sound": "Hang lejátszása",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Befejezve",
   "onboarding.next": "Következő",
   "onboarding.page_five.public_timelines": "A helyi idővonal mindenkinek a publikus posztját mutatja a(z) {domain}-n. A federált idővonal mindenki publikus posztját mutatja akit {domain} felhasználói követnek. Ezek a publikus idővonalak, nagyszerű mód új emberek megismerésére.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Értesítések",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "A piszkozata el fog vesztődni ha elhagyja Mastodon-t.",
   "upload_area.title": "Húzza ide a feltöltéshez",
   "upload_button.label": "Média hozzáadása",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index a0442bad4..76b3a9c12 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Արգելափակել @{name}֊ին",
   "account.block_domain": "Թաքցնել ամենը հետեւյալ տիրույթից՝ {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Նավարկություն",
   "column_subheading.settings": "Կարգավորումներ",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Այս թութը չի հաշվառվի որեւէ պիտակի տակ, քանզի այն ծածուկ է։ Միայն հրապարակային թթերը հնարավոր է որոնել պիտակներով։",
   "compose_form.lock_disclaimer": "Քո հաշիվը {locked} չէ։ Յուրաքանչյուր ոք կարող է հետեւել քեզ եւ տեսնել միայն հետեւողների համար նախատեսված գրառումները։",
   "compose_form.lock_disclaimer.lock": "փակ",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Տարածածներից՝",
   "notifications.column_settings.show": "Ցուցադրել սյունում",
   "notifications.column_settings.sound": "Ձայն հանել",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Պատրաստ է",
   "onboarding.next": "Հաջորդ",
   "onboarding.page_five.public_timelines": "Տեղական հոսքը ցույց է տալիս {domain} տիրույթից բոլորի հրապարակային թթերը։ Դաշնային հոսքը ցույց է տալիս հրապարակային թթերը բոլորից, ում {domain} տիրույթի մարդիկ հետեւում են։ Սրանք Հրապարակային հոսքերն են՝ նոր մարդկանց բացահայտելու հրաշալի միջոց։",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Տեղական",
   "tabs_bar.notifications": "Ծանուցումներ",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Քո սեւագիրը կկորի, եթե լքես Մաստոդոնը։",
   "upload_area.title": "Քաշիր ու նետիր՝ վերբեռնելու համար",
   "upload_button.label": "Ավելացնել մեդիա",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 2fd922544..00240530d 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokir @{name}",
   "account.block_domain": "Sembunyikan segalanya dari {domain}",
   "account.blocked": "Terblokir",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigasi",
   "column_subheading.settings": "Pengaturan",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Toot ini tidak akan ada dalam daftar tagar manapun karena telah di set sebagai tidak terdaftar. Hanya postingan publik yang bisa dicari dengan tagar.",
   "compose_form.lock_disclaimer": "Akun anda tidak {locked}. Semua orang dapat mengikuti anda untuk melihat postingan khusus untuk pengikut anda.",
   "compose_form.lock_disclaimer.lock": "terkunci",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Boost:",
   "notifications.column_settings.show": "Tampilkan dalam kolom",
   "notifications.column_settings.sound": "Mainkan suara",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Selesei",
   "onboarding.next": "Selanjutnya",
   "onboarding.page_five.public_timelines": "Linimasa lokal menampilkan semua postingan publik dari semua orang di {domain}. Linimasa gabungan menampilkan postingan publik dari semua orang yang diikuti oleh {domain}. Ini semua adalah Linimasa Publik, cara terbaik untuk bertemu orang lain.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Notifikasi",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Naskah anda akan hilang jika anda keluar dari Mastodon.",
   "upload_area.title": "Seret & lepaskan untuk mengunggah",
   "upload_button.label": "Tambahkan media",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index ed45ee11e..d20ee0f62 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokusar @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Repeti:",
   "notifications.column_settings.show": "Montrar en kolumno",
   "notifications.column_settings.sound": "Plear sono",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Done",
   "onboarding.next": "Next",
   "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Lokala",
   "tabs_bar.notifications": "Savigi",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Tranar faligar por kargar",
   "upload_button.label": "Adjuntar kontenajo",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index a7ca62015..cc0551b14 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -1,173 +1,175 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blocca @{name}",
-  "account.block_domain": "Hide everything from {domain}",
+  "account.block_domain": "Nascondi tutto da {domain}",
   "account.blocked": "Bloccato",
   "account.direct": "Direct Message @{name}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
-  "account.domain_blocked": "Domain hidden",
+  "account.disclaimer_full": "Il profilo dell'utente mostrato qui sotto potrebbe essere incompleto.",
+  "account.domain_blocked": "Dominio nascosto",
   "account.edit_profile": "Modifica profilo",
   "account.follow": "Segui",
   "account.followers": "Seguaci",
   "account.follows": "Segue",
   "account.follows_you": "Ti segue",
-  "account.hide_reblogs": "Hide boosts from @{name}",
+  "account.hide_reblogs": "Nascondi condivisioni da @{name}",
   "account.media": "Media",
   "account.mention": "Menziona @{name}",
-  "account.moved_to": "{name} has moved to:",
+  "account.moved_to": "{name} si è trasferito su:",
   "account.mute": "Silenzia @{name}",
-  "account.mute_notifications": "Mute notifications from @{name}",
-  "account.muted": "Muted",
+  "account.mute_notifications": "Silenzia notifiche da @{name}",
+  "account.muted": "Silenziato",
   "account.posts": "Toot",
-  "account.posts_with_replies": "Toot con risposte",
+  "account.posts_with_replies": "Toot e risposte",
   "account.report": "Segnala @{name}",
   "account.requested": "In attesa di approvazione",
-  "account.share": "Share @{name}'s profile",
-  "account.show_reblogs": "Show boosts from @{name}",
+  "account.share": "Condividi il profilo di @{name}",
+  "account.show_reblogs": "Mostra condivisioni da @{name}",
   "account.unblock": "Sblocca @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "Non nascondere {domain}",
   "account.unfollow": "Non seguire",
   "account.unmute": "Non silenziare @{name}",
-  "account.unmute_notifications": "Unmute notifications from @{name}",
-  "account.view_full_profile": "View full profile",
-  "alert.unexpected.message": "An unexpected error occurred.",
+  "account.unmute_notifications": "Non silenziare più le notifiche da @{name}",
+  "account.view_full_profile": "Vedi profilo completo",
+  "alert.unexpected.message": "Si è verificato un errore inatteso.",
   "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
+  "bundle_column_error.body": "E' avvenuto un errore durante il caricamento di questo componente.",
+  "bundle_column_error.retry": "Riprova",
   "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
+  "bundle_modal_error.close": "Chiudi",
+  "bundle_modal_error.message": "C'è stato un errore mentre questo componente veniva caricato.",
+  "bundle_modal_error.retry": "Riprova",
   "column.blocks": "Utenti bloccati",
   "column.community": "Timeline locale",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Messaggi diretti",
+  "column.domain_blocks": "Domini nascosti",
   "column.favourites": "Apprezzati",
   "column.follow_requests": "Richieste di amicizia",
   "column.home": "Home",
-  "column.lists": "Lists",
+  "column.lists": "Liste",
   "column.mutes": "Utenti silenziati",
   "column.notifications": "Notifiche",
   "column.pins": "Pinned toot",
   "column.public": "Timeline federata",
   "column_back_button.label": "Indietro",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
-  "column_header.pin": "Pin",
-  "column_header.show_settings": "Show settings",
-  "column_header.unpin": "Unpin",
+  "column_header.hide_settings": "Nascondi impostazioni",
+  "column_header.moveLeft_settings": "Sposta colonna a sinistra",
+  "column_header.moveRight_settings": "Sposta colonna a destra",
+  "column_header.pin": "Fissa in cima",
+  "column_header.show_settings": "Mostra impostazioni",
+  "column_header.unpin": "Non fissare in cima",
   "column_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
-  "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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
-  "compose_form.lock_disclaimer.lock": "locked",
+  "column_subheading.settings": "Impostazioni",
+  "compose_form.direct_message_warning": "Questo toot sarà mandato solo a tutti gli utenti menzionati.",
+  "compose_form.direct_message_warning_learn_more": "Per saperne di piu'",
+  "compose_form.hashtag_warning": "Questo toot non è listato, quindi non sarà trovato nelle ricerche per hashtag. Solo i toot pubblici possono essere cercati per hashtag.",
+  "compose_form.lock_disclaimer": "Il tuo account non è {bloccato}. Chiunque può decidere di seguirti per vedere i tuoi post per soli seguaci.",
+  "compose_form.lock_disclaimer.lock": "bloccato",
   "compose_form.placeholder": "A cosa stai pensando?",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.sensitive.marked": "Questo media è contrassegnato come sensibile",
+  "compose_form.sensitive.unmarked": "Questo media non è contrassegnato come sensibile",
+  "compose_form.spoiler.marked": "Il testo è nascosto dall'avviso",
+  "compose_form.spoiler.unmarked": "Il testo non è nascosto",
   "compose_form.spoiler_placeholder": "Content warning",
-  "confirmation_modal.cancel": "Cancel",
+  "confirmation_modal.cancel": "Annulla",
   "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.block.message": "Sei sicuro di voler bloccare {name}?",
   "confirmations.delete.confirm": "Delete",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.delete.message": "Sei sicuro di voler cancellare questo status?",
   "confirmations.delete_list.confirm": "Delete",
-  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
-  "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.mute.confirm": "Mute",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "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",
+  "confirmations.delete_list.message": "Sei sicuro di voler cancellare definitivamente questa lista?",
+  "confirmations.domain_block.confirm": "Nascondi intero dominio",
+  "confirmations.domain_block.message": "Sei davvero sicuro che vuoi bloccare l'intero {domain}? Nella maggior parte dei casi, pochi blocchi o silenziamenti mirati sono sufficienti e preferibili.",
+  "confirmations.mute.confirm": "Silenzia",
+  "confirmations.mute.message": "Sei sicuro di voler silenziare {name}?",
+  "confirmations.unfollow.confirm": "Smetti di seguire",
+  "confirmations.unfollow.message": "Sei sicuro che non vuoi più seguire {name}?",
+  "embed.instructions": "Inserisci questo status nel tuo sito copiando il codice qui sotto.",
+  "embed.preview": "Ecco come apparirà:",
+  "emoji_button.activity": "Attività",
+  "emoji_button.custom": "Personalizzato",
+  "emoji_button.flags": "Bandiere",
+  "emoji_button.food": "Cibo e bevande",
   "emoji_button.label": "Inserisci emoji",
-  "emoji_button.nature": "Nature",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.recent": "Frequently used",
-  "emoji_button.search": "Search...",
-  "emoji_button.search_results": "Search results",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
+  "emoji_button.nature": "Natura",
+  "emoji_button.not_found": "Nessun emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Oggetti",
+  "emoji_button.people": "Persone",
+  "emoji_button.recent": "Usati di frequente",
+  "emoji_button.search": "Cerca...",
+  "emoji_button.search_results": "Risultati della ricerca",
+  "emoji_button.symbols": "Simboli",
+  "emoji_button.travel": "Viaggi e luoghi",
   "empty_column.community": "La timeline locale è vuota. Condividi qualcosa pubblicamente per dare inizio alla festa!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Non hai ancora nessun messaggio diretto. Quando ne manderai o riceverai qualcuno, apparirà qui.",
   "empty_column.hashtag": "Non c'è ancora nessun post con questo hashtag.",
   "empty_column.home": "Non stai ancora seguendo nessuno. Visita {public} o usa la ricerca per incontrare nuove persone.",
   "empty_column.home.public_timeline": "la timeline pubblica",
   "empty_column.list": "Non c'è niente in questo elenco ancora. Quando i membri di questo elenco postano nuovi stati, questi appariranno qui.",
   "empty_column.notifications": "Non hai ancora nessuna notifica. Interagisci con altri per iniziare conversazioni.",
-  "empty_column.public": "Qui non c'è nulla! Scrivi qualcosa pubblicamente, o aggiungi utenti da altri server per riempire questo spazio.",
+  "empty_column.public": "Qui non c'è nulla! Scrivi qualcosa pubblicamente, o aggiungi utenti da altri server per riempire questo spazio",
   "follow_request.authorize": "Autorizza",
   "follow_request.reject": "Rifiuta",
-  "getting_started.appsshort": "Apps",
+  "getting_started.appsshort": "App",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Come iniziare",
   "getting_started.open_source_notice": "Mastodon è un software open source. Puoi contribuire o segnalare errori su GitHub all'indirizzo {github}.",
-  "getting_started.userguide": "User Guide",
+  "getting_started.userguide": "Manuale utente",
   "home.column_settings.advanced": "Avanzato",
   "home.column_settings.basic": "Semplice",
   "home.column_settings.filter_regex": "Filtra con espressioni regolari",
   "home.column_settings.show_reblogs": "Mostra post condivisi",
   "home.column_settings.show_replies": "Mostra risposte",
   "home.settings": "Impostazioni colonna",
-  "keyboard_shortcuts.back": "to navigate back",
-  "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
-  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.back": "per tornare indietro",
+  "keyboard_shortcuts.boost": "per condividere",
+  "keyboard_shortcuts.column": "per portare il focus su uno status in una delle colonne",
+  "keyboard_shortcuts.compose": "per portare il focus nell'area di composizione",
   "keyboard_shortcuts.description": "Description",
-  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.down": "per spostarsi in basso nella lista",
   "keyboard_shortcuts.enter": "to open status",
-  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.favourite": "per segnare come apprezzato",
   "keyboard_shortcuts.heading": "Keyboard Shortcuts",
   "keyboard_shortcuts.hotkey": "Hotkey",
-  "keyboard_shortcuts.legend": "to display this legend",
-  "keyboard_shortcuts.mention": "to mention author",
-  "keyboard_shortcuts.reply": "to reply",
-  "keyboard_shortcuts.search": "to focus search",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
-  "keyboard_shortcuts.toot": "to start a brand new toot",
-  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
-  "keyboard_shortcuts.up": "to move up in the list",
+  "keyboard_shortcuts.legend": "per mostrare questa spiegazione",
+  "keyboard_shortcuts.mention": "per menzionare l'autore",
+  "keyboard_shortcuts.reply": "per rispondere",
+  "keyboard_shortcuts.search": "per spostare il focus sulla ricerca",
+  "keyboard_shortcuts.toggle_hidden": "per mostrare/nascondere il testo dei CW",
+  "keyboard_shortcuts.toot": "per iniziare a scrivere un toot completamente nuovo",
+  "keyboard_shortcuts.unfocus": "per uscire dall'area di composizione o dalla ricerca",
+  "keyboard_shortcuts.up": "per spostarsi in alto nella lista",
   "lightbox.close": "Chiudi",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
-  "lists.account.add": "Add to list",
-  "lists.account.remove": "Remove from list",
+  "lightbox.next": "Successivo",
+  "lightbox.previous": "Precedente",
+  "lists.account.add": "Aggiungi alla lista",
+  "lists.account.remove": "Togli dalla lista",
   "lists.delete": "Delete list",
-  "lists.edit": "Edit list",
-  "lists.new.create": "Add list",
-  "lists.new.title_placeholder": "New list title",
-  "lists.search": "Search among people you follow",
-  "lists.subheading": "Your lists",
+  "lists.edit": "Modifica lista",
+  "lists.new.create": "Aggiungi lista",
+  "lists.new.title_placeholder": "Titolo della nuova lista",
+  "lists.search": "Cerca tra le persone che segui",
+  "lists.subheading": "Le tue liste",
   "loading_indicator.label": "Carico...",
   "media_gallery.toggle_visible": "Imposta visibilità",
   "missing_indicator.label": "Non trovato",
-  "missing_indicator.sublabel": "This resource could not be found",
-  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "missing_indicator.sublabel": "Risorsa non trovata",
+  "mute_modal.hide_notifications": "Nascondere le notifiche da quest'utente?",
   "navigation_bar.blocks": "Utenti bloccati",
   "navigation_bar.community_timeline": "Timeline locale",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Messaggi diretti",
+  "navigation_bar.domain_blocks": "Domini nascosti",
   "navigation_bar.edit_profile": "Modifica profilo",
   "navigation_bar.favourites": "Apprezzati",
   "navigation_bar.follow_requests": "Richieste di amicizia",
   "navigation_bar.info": "Informazioni estese",
-  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
-  "navigation_bar.lists": "Lists",
-  "navigation_bar.logout": "Logout",
+  "navigation_bar.keyboard_shortcuts": "Tasti di scelta rapida",
+  "navigation_bar.lists": "Liste",
+  "navigation_bar.logout": "Esci",
   "navigation_bar.mutes": "Utenti silenziati",
-  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.pins": "Toot fissati in cima",
   "navigation_bar.preferences": "Impostazioni",
   "navigation_bar.public_timeline": "Timeline federata",
   "notification.favourite": "{name} ha apprezzato il tuo post",
@@ -180,32 +182,33 @@
   "notifications.column_settings.favourite": "Apprezzati:",
   "notifications.column_settings.follow": "Nuovi seguaci:",
   "notifications.column_settings.mention": "Menzioni:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "Notifiche push",
+  "notifications.column_settings.push_meta": "Questo dispositivo",
   "notifications.column_settings.reblog": "Post condivisi:",
   "notifications.column_settings.show": "Mostra in colonna",
   "notifications.column_settings.sound": "Riproduci suono",
-  "onboarding.done": "Done",
-  "onboarding.next": "Next",
-  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
-  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
-  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
-  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
-  "onboarding.page_one.full_handle": "Your full handle",
-  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
-  "onboarding.page_one.welcome": "Welcome to Mastodon!",
-  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
-  "onboarding.page_six.almost_done": "Almost done...",
-  "onboarding.page_six.appetoot": "Bon Appetoot!",
-  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
-  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
-  "onboarding.page_six.guidelines": "community guidelines",
-  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
-  "onboarding.page_six.various_app": "mobile apps",
-  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
-  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
-  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
-  "onboarding.skip": "Skip",
+  "notifications.group": "{count} notifiche",
+  "onboarding.done": "Fatto",
+  "onboarding.next": "Prossimo",
+  "onboarding.page_five.public_timelines": "La timeline locale mostra i post pubblici di tutti gli utenti di {domain}. La timeline federata mostra i post pubblici di tutti gli utenti seguiti da quelli di {domain}. Queste sono le timeline pubbliche, che vi danno grandi possibilità di scoprire nuovi utenti.",
+  "onboarding.page_four.home": "La timeline home mostra i post degli utenti che segui.",
+  "onboarding.page_four.notifications": "La colonna delle notifiche ti fa vedere quando qualcuno interagisce con te.",
+  "onboarding.page_one.federation": "Mastodon è una rete di server indipendenti che si collegano tra loro per formare un grande social network. I singoli server sono detti istanze.",
+  "onboarding.page_one.full_handle": "Il tuo nome utente completo",
+  "onboarding.page_one.handle_hint": "È ciò che diresti ai tuoi amici di cercare per trovarti.",
+  "onboarding.page_one.welcome": "Benvenuto in Mastodon!",
+  "onboarding.page_six.admin": "L'amministratore della tua istanza è {admin}.",
+  "onboarding.page_six.almost_done": "Quasi finito...",
+  "onboarding.page_six.appetoot": "Buon appetoot!",
+  "onboarding.page_six.apps_available": "Esistono {apps} per iOS, Android e altre piattaforme.",
+  "onboarding.page_six.github": "Mastodon è software libero e open-source. Puoi segnalare bug, richiedere nuove funzionalità, o contribuire al codice su {github}.",
+  "onboarding.page_six.guidelines": "linee guida per la comunità",
+  "onboarding.page_six.read_guidelines": "Ti preghiamo di leggere le {guidelines} di {domain}!",
+  "onboarding.page_six.various_app": "app per dispositivi mobili",
+  "onboarding.page_three.profile": "Puoi modificare il tuo profilo per cambiare i tuoi avatar, biografia e nome pubblico. E potrai trovarci altre preferenze.",
+  "onboarding.page_three.search": "Usa la barra di ricerca per trovare persone e hashtag, come {illustration} e {introductions}. Per trovare una persona che non è su questa istanza, usa il suo nome utente completo.",
+  "onboarding.page_two.compose": "Puoi scrivere dei post dalla colonna di composizione. Puoi caricare immagini, modificare le impostazioni di privacy, e aggiungere avvisi sul contenuto con le icone qui sotto.",
+  "onboarding.skip": "Salta",
   "privacy.change": "Modifica privacy post",
   "privacy.direct.long": "Invia solo a utenti menzionati",
   "privacy.direct.short": "Diretto",
@@ -215,82 +218,84 @@
   "privacy.public.short": "Pubblico",
   "privacy.unlisted.long": "Non mostrare sulla timeline pubblica",
   "privacy.unlisted.short": "Non elencato",
-  "regeneration_indicator.label": "Loading…",
-  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "regeneration_indicator.label": "Caricamento in corso…",
+  "regeneration_indicator.sublabel": "Stiamo preparando il tuo home feed!",
   "relative_time.days": "{number}d",
   "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
+  "relative_time.just_now": "ora",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
   "reply_indicator.cancel": "Annulla",
-  "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.forward": "Inoltra a {target}",
+  "report.forward_hint": "Questo account appartiene a un altro server. Mandare anche là una copia anonima del rapporto?",
+  "report.hint": "La segnalazione sara' invata ai tuoi moderatori di istanza. Di seguito, puoi fornire  il motivo per il quale stai segnalando questo account:",
   "report.placeholder": "Commenti aggiuntivi",
   "report.submit": "Invia",
-  "report.target": "Invio la segnalazione",
+  "report.target": "Invio la segnalazione {target}",
   "search.placeholder": "Cerca",
-  "search_popout.search_format": "Advanced search format",
+  "search_popout.search_format": "Formato di ricerca avanzato",
   "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_popout.tips.user": "utente",
+  "search_results.accounts": "Gente",
+  "search_results.hashtags": "Hashtag",
+  "search_results.statuses": "Toot",
   "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "Un'occhiata all'interno...",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
-  "status.cannot_reblog": "This post cannot be boosted",
+  "status.cancel_reblog_private": "Annulla condivisione",
+  "status.cannot_reblog": "Questo post non può essere condiviso",
   "status.delete": "Elimina",
-  "status.direct": "Direct message @{name}",
-  "status.embed": "Embed",
+  "status.direct": "Messaggio diretto @{name}",
+  "status.embed": "Incorpora",
   "status.favourite": "Apprezzato",
   "status.load_more": "Mostra di più",
   "status.media_hidden": "Allegato nascosto",
   "status.mention": "Nomina @{name}",
-  "status.more": "More",
-  "status.mute": "Mute @{name}",
-  "status.mute_conversation": "Mute conversation",
+  "status.more": "Altro",
+  "status.mute": "Silenzia @{name}",
+  "status.mute_conversation": "Silenzia conversazione",
   "status.open": "Espandi questo post",
-  "status.pin": "Pin on profile",
-  "status.pinned": "Pinned toot",
+  "status.pin": "Fissa in cima sul profilo",
+  "status.pinned": "Toot fissato in cima",
   "status.reblog": "Condividi",
   "status.reblog_private": "Boost to original audience",
   "status.reblogged_by": "{name} ha condiviso",
   "status.reply": "Rispondi",
-  "status.replyAll": "Reply to thread",
+  "status.replyAll": "Rispondi alla conversazione",
   "status.report": "Segnala @{name}",
   "status.sensitive_toggle": "Clicca per vedere",
   "status.sensitive_warning": "Materiale sensibile",
-  "status.share": "Share",
+  "status.share": "Condividi",
   "status.show_less": "Mostra meno",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "Mostra meno per tutti",
   "status.show_more": "Mostra di più",
-  "status.show_more_all": "Show more for all",
-  "status.unmute_conversation": "Unmute conversation",
-  "status.unpin": "Unpin from profile",
+  "status.show_more_all": "Mostra di più per tutti",
+  "status.unmute_conversation": "Annulla silenzia conversazione",
+  "status.unpin": "Non fissare in cima al profilo",
   "tabs_bar.federated_timeline": "Federazione",
   "tabs_bar.home": "Home",
   "tabs_bar.local_timeline": "Locale",
   "tabs_bar.notifications": "Notifiche",
-  "tabs_bar.search": "Search",
-  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
+  "tabs_bar.search": "Cerca",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
+  "ui.beforeunload": "La bozza andrà persa se esci da Mastodon.",
   "upload_area.title": "Trascina per caricare",
   "upload_button.label": "Aggiungi file multimediale",
-  "upload_form.description": "Describe for the visually impaired",
-  "upload_form.focus": "Crop",
-  "upload_form.undo": "Annulla",
+  "upload_form.description": "Descrizione per utenti con disabilità visive",
+  "upload_form.focus": "Rifila",
+  "upload_form.undo": "Cancella",
   "upload_progress.label": "Sto caricando...",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
+  "video.close": "Chiudi video",
+  "video.exit_fullscreen": "Esci da modalità a schermo intero",
+  "video.expand": "Espandi video",
   "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
+  "video.hide": "Nascondi video",
+  "video.mute": "Silenzia suono",
   "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "video.play": "Avvia",
+  "video.unmute": "Riattiva suono"
 }
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index dbb4562de..8de92db16 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "@{name}さんをブロック",
   "account.block_domain": "{domain}全体を非表示",
   "account.blocked": "ブロック済み",
@@ -62,10 +63,11 @@
   "column_subheading.lists": "リスト",
   "column_subheading.navigation": "ナビゲーション",
   "column_subheading.settings": "設定",
-  "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.lock_disclaimer.lock": "承認制",
   "compose_form.placeholder": "今なにしてる?",
   "compose_form.publish": "トゥート",
   "compose_form.publish_loud": "{publish}!",
@@ -189,6 +191,7 @@
   "notifications.column_settings.reblog": "ブースト:",
   "notifications.column_settings.show": "カラムに表示",
   "notifications.column_settings.sound": "通知音を再生",
+  "notifications.group": "{count} 件の通知",
   "onboarding.done": "完了",
   "onboarding.next": "次へ",
   "onboarding.page_five.public_timelines": "連合タイムラインでは{domain}の人がフォローしているMastodon全体での公開投稿を表示します。同じくローカルタイムラインでは{domain}のみの公開投稿を表示します。",
@@ -214,7 +217,7 @@
   "privacy.direct.long": "メンションしたユーザーだけに公開",
   "privacy.direct.short": "ダイレクト",
   "privacy.private.long": "フォロワーだけに公開",
-  "privacy.private.short": "非公開",
+  "privacy.private.short": "フォロワー限定",
   "privacy.public.long": "公開TLに投稿する",
   "privacy.public.short": "公開",
   "privacy.unlisted.long": "公開TLで表示しない",
@@ -281,12 +284,14 @@
   "tabs_bar.local_timeline": "ローカル",
   "tabs_bar.notifications": "通知",
   "tabs_bar.search": "検索",
+  "timeline.media": "Media",
+  "timeline.posts": "投稿",
   "ui.beforeunload": "Mastodonから離れると送信前の投稿は失われます。",
   "upload_area.title": "ドラッグ&ドロップでアップロード",
   "upload_button.label": "メディアを追加",
   "upload_form.description": "視覚障害者のための説明",
   "upload_form.focus": "焦点",
-  "upload_form.undo": "やり直す",
+  "upload_form.undo": "削除",
   "upload_progress.label": "アップロード中...",
   "video.close": "動画を閉じる",
   "video.exit_fullscreen": "全画面を終了する",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 2a2734673..edcf5f817 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "@{name}을 차단",
   "account.block_domain": "{domain} 전체를 숨김",
   "account.blocked": "차단 됨",
@@ -29,9 +30,9 @@
   "account.unmute": "뮤트 해제",
   "account.unmute_notifications": "@{name}의 알림 뮤트 해제",
   "account.view_full_profile": "전체 프로필 보기",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
-  "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.",
+  "alert.unexpected.message": "예측하지 못한 에러가 발생했습니다.",
+  "alert.unexpected.title": "앗!",
+  "boost_modal.combo": "{combo}를 누르면 다음부터 이 과정을 건너뛸 수 있습니다",
   "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
   "bundle_column_error.retry": "다시 시도",
   "bundle_column_error.title": "네트워크 에러",
@@ -40,8 +41,8 @@
   "bundle_modal_error.retry": "다시 시도",
   "column.blocks": "차단 중인 사용자",
   "column.community": "로컬 타임라인",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "다이렉트 메시지",
+  "column.domain_blocks": "숨겨진 도메인",
   "column.favourites": "즐겨찾기",
   "column.follow_requests": "팔로우 요청",
   "column.home": "홈",
@@ -59,17 +60,18 @@
   "column_header.unpin": "고정 해제",
   "column_subheading.navigation": "내비게이션",
   "column_subheading.settings": "설정",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "이 툿은 멘션 된 유저들에게만 보여집니다.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "이 툿은 어떤 해시태그로도 검색 되지 않습니다. 전체공개로 게시 된 툿만이 해시태그로 검색 될 수 있습니다.",
   "compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
   "compose_form.lock_disclaimer.lock": "비공개",
   "compose_form.placeholder": "지금 무엇을 하고 있나요?",
   "compose_form.publish": "툿",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.sensitive.marked": "미디어가 열람주의로 설정되어 있습니다",
+  "compose_form.sensitive.unmarked": "미디어가 열람주의로 설정 되어 있지 않습니다",
+  "compose_form.spoiler.marked": "열람주의가 설정되어 있습니다",
+  "compose_form.spoiler.unmarked": "열람주의가 설정 되어 있지 않습니다",
   "compose_form.spoiler_placeholder": "경고",
   "confirmation_modal.cancel": "취소",
   "confirmations.block.confirm": "차단",
@@ -101,13 +103,13 @@
   "emoji_button.symbols": "기호",
   "emoji_button.travel": "여행과 장소",
   "empty_column.community": "로컬 타임라인에 아무 것도 없습니다. 아무거나 적어 보세요!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "아직 다이렉트 메시지가 없습니다. 다이렉트 메시지를 보내거나 받은 경우, 여기에 표시 됩니다.",
   "empty_column.hashtag": "이 해시태그는 아직 사용되지 않았습니다.",
   "empty_column.home": "아직 아무도 팔로우 하고 있지 않습니다. {public}를 보러 가거나, 검색하여 다른 사용자를 찾아 보세요.",
   "empty_column.home.public_timeline": "연합 타임라인",
   "empty_column.list": "리스트에 아직 아무 것도 없습니다.",
-  "empty_column.notifications": "아직 알림이 없습니다. 다른 사람과 대화를 시작해 보세요!",
-  "empty_column.public": "여기엔 아직 아무 것도 없습니다! 공개적으로 무언가 포스팅하거나, 다른 인스턴스 유저를 팔로우 해서 가득 채워보세요!",
+  "empty_column.notifications": "아직 알림이 없습니다. 다른 사람과 대화를 시작해 보세요.",
+  "empty_column.public": "여기엔 아직 아무 것도 없습니다! 공개적으로 무언가 포스팅하거나, 다른 인스턴스의 유저를 팔로우 해서 채워보세요",
   "follow_request.authorize": "허가",
   "follow_request.reject": "거부",
   "getting_started.appsshort": "애플리케이션",
@@ -135,7 +137,7 @@
   "keyboard_shortcuts.mention": "멘션",
   "keyboard_shortcuts.reply": "답장",
   "keyboard_shortcuts.search": "검색창에 포커스",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "CW로 가려진 텍스트를 표시/비표시",
   "keyboard_shortcuts.toot": "새 툿 작성",
   "keyboard_shortcuts.unfocus": "작성창에서 포커스 해제",
   "keyboard_shortcuts.up": "리스트에서 위로 이동",
@@ -157,8 +159,8 @@
   "mute_modal.hide_notifications": "이 사용자로부터의 알림을 뮤트하시겠습니까?",
   "navigation_bar.blocks": "차단한 사용자",
   "navigation_bar.community_timeline": "로컬 타임라인",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "다이렉트 메시지",
+  "navigation_bar.domain_blocks": "숨겨진 도메인",
   "navigation_bar.edit_profile": "프로필 편집",
   "navigation_bar.favourites": "즐겨찾기",
   "navigation_bar.follow_requests": "팔로우 요청",
@@ -177,14 +179,15 @@
   "notifications.clear": "알림 지우기",
   "notifications.clear_confirmation": "정말로 알림을 삭제하시겠습니까?",
   "notifications.column_settings.alert": "데스크탑 알림",
-  "notifications.column_settings.favourite": "즐겨찾기",
-  "notifications.column_settings.follow": "새 팔로워",
-  "notifications.column_settings.mention": "답글",
+  "notifications.column_settings.favourite": "즐겨찾기:",
+  "notifications.column_settings.follow": "새 팔로워:",
+  "notifications.column_settings.mention": "답글:",
   "notifications.column_settings.push": "푸시 알림",
   "notifications.column_settings.push_meta": "이 장치",
-  "notifications.column_settings.reblog": "부스트",
+  "notifications.column_settings.reblog": "부스트:",
   "notifications.column_settings.show": "컬럼에 표시",
   "notifications.column_settings.sound": "효과음 재생",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "완료",
   "onboarding.next": "다음",
   "onboarding.page_five.public_timelines": "연합 타임라인에서는 {domain}의 사람들이 팔로우 중인 Mastodon 전체 인스턴스의 공개 포스트를 표시합니다. 로컬 타임라인에서는 {domain} 만의 공개 포스트를 표시합니다.",
@@ -200,7 +203,7 @@
   "onboarding.page_six.apps_available": "iOS、Android 또는 다른 플랫폼에서 사용할 수 있는 {apps}이 있습니다.",
   "onboarding.page_six.github": "Mastodon는 오픈 소스 소프트웨어입니다. 버그 보고나 기능 추가 요청, 기여는 {github}에서 할 수 있습니다.",
   "onboarding.page_six.guidelines": "커뮤니티 가이드라인",
-  "onboarding.page_six.read_guidelines": "{guidelines}을 확인하는 것을 잊지 마세요.",
+  "onboarding.page_six.read_guidelines": "{domain}의 {guidelines}을 확인하는 것을 잊지 마세요!",
   "onboarding.page_six.various_app": "다양한 모바일 애플리케이션",
   "onboarding.page_three.profile": "[프로필 편집] 에서 자기 소개나 이름을 변경할 수 있습니다. 또한 다른 설정도 변경할 수 있습니다.",
   "onboarding.page_three.search": "검색 바에서 {illustration} 나 {introductions} 와 같이 특정 해시태그가 달린 포스트를 보거나, 사용자를 찾을 수 있습니다.",
@@ -242,10 +245,10 @@
   "search_results.total": "{count, number}건의 결과",
   "standalone.public_title": "지금 이런 이야기를 하고 있습니다…",
   "status.block": "@{name} 차단",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "부스트 취소",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
   "status.delete": "삭제",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "@{name}에게 다이렉트 메시지",
   "status.embed": "공유하기",
   "status.favourite": "즐겨찾기",
   "status.load_more": "더 보기",
@@ -258,7 +261,7 @@
   "status.pin": "고정",
   "status.pinned": "고정 된 툿",
   "status.reblog": "부스트",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "원래의 수신자들에게 부스트",
   "status.reblogged_by": "{name}님이 부스트 했습니다",
   "status.reply": "답장",
   "status.replyAll": "전원에게 답장",
@@ -267,22 +270,24 @@
   "status.sensitive_warning": "민감한 미디어",
   "status.share": "공유",
   "status.show_less": "숨기기",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "모두 접기",
   "status.show_more": "더 보기",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "모두 펼치기",
   "status.unmute_conversation": "이 대화의 뮤트 해제하기",
   "status.unpin": "고정 해제",
   "tabs_bar.federated_timeline": "연합",
   "tabs_bar.home": "홈",
   "tabs_bar.local_timeline": "로컬",
   "tabs_bar.notifications": "알림",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "검색",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.",
   "upload_area.title": "드래그 & 드롭으로 업로드",
   "upload_button.label": "미디어 추가",
   "upload_form.description": "시각장애인을 위한 설명",
   "upload_form.focus": "크롭",
-  "upload_form.undo": "재시도",
+  "upload_form.undo": "삭제",
   "upload_progress.label": "업로드 중...",
   "video.close": "동영상 닫기",
   "video.exit_fullscreen": "전체화면 나가기",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index adc1d19a7..ee4d9de47 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokkeer @{name}",
   "account.block_domain": "Negeer alles van {domain}",
   "account.blocked": "Geblokkeerd",
@@ -50,7 +51,7 @@
   "column.notifications": "Meldingen",
   "column.pins": "Vastgezette toots",
   "column.public": "Globale tijdlijn",
-  "column_back_button.label": "terug",
+  "column_back_button.label": "Terug",
   "column_header.hide_settings": "Instellingen verbergen",
   "column_header.moveLeft_settings": "Kolom naar links verplaatsen",
   "column_header.moveRight_settings": "Kolom naar rechts verplaatsen",
@@ -59,9 +60,10 @@
   "column_header.unpin": "Losmaken",
   "column_subheading.navigation": "Navigatie",
   "column_subheading.settings": "Instellingen",
-  "compose_form.direct_message_warning": "Deze toot zal alleen zichtbaar zijn voor alle vermelde gebruikers.",
+  "compose_form.direct_message_warning": "Deze toot wordt alleen naar vermelde gebruikers verstuurd. Echter, de beheerders en moderatoren van jouw en de ontvangende Mastodonserver(s) kunnen dit bericht mogelijk wel bekijken.",
+  "compose_form.direct_message_warning_learn_more": "Meer leren",
   "compose_form.hashtag_warning": "Deze toot valt niet onder een hashtag te bekijken, omdat deze niet op openbare tijdlijnen wordt getoond. Alleen openbare toots kunnen via hashtags gevonden worden.",
-  "compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en toots zien die je alleen aan volgers hebt gericht.",
+  "compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en kan de toots zien die je alleen aan jouw volgers hebt gericht.",
   "compose_form.lock_disclaimer.lock": "besloten",
   "compose_form.placeholder": "Wat wil je kwijt?",
   "compose_form.publish": "Toot",
@@ -76,10 +78,10 @@
   "confirmations.block.message": "Weet je het zeker dat je {name} wilt blokkeren?",
   "confirmations.delete.confirm": "Verwijderen",
   "confirmations.delete.message": "Weet je het zeker dat je deze toot wilt verwijderen?",
-  "confirmations.delete_list.confirm": "Delete",
+  "confirmations.delete_list.confirm": "Verwijderen",
   "confirmations.delete_list.message": "Weet je zeker dat je deze lijst definitief wilt verwijderen?",
   "confirmations.domain_block.confirm": "Negeer alles van deze server",
-  "confirmations.domain_block.message": "Weet je het echt, echt zeker dat je alles van {domain} wil negeren? In de meeste gevallen is het blokkeren of negeren van een paar specifieke personen voldoende en gewenst.",
+  "confirmations.domain_block.message": "Weet je het echt heel erg zeker dat je alles van {domain} wil negeren? In de meeste gevallen is het blokkeren of negeren van een paar specifieke personen voldoende en gepaster.",
   "confirmations.mute.confirm": "Negeren",
   "confirmations.mute.message": "Weet je het zeker dat je {name} wilt negeren?",
   "confirmations.unfollow.confirm": "Ontvolgen",
@@ -106,13 +108,13 @@
   "empty_column.home": "Jij volgt nog niemand. Bezoek {public} of gebruik het zoekvenster om andere mensen te ontmoeten.",
   "empty_column.home.public_timeline": "de globale tijdlijn",
   "empty_column.list": "Er is nog niks in deze lijst. Wanneer lijstleden nieuwe toots publiceren, zijn deze hier te zien.",
-  "empty_column.notifications": "Je hebt nog geen meldingen. Heb interactie met andere mensen om het gesprek aan te gaan.",
+  "empty_column.notifications": "Je hebt nog geen meldingen. Begin met iemand een gesprek.",
   "empty_column.public": "Er is hier helemaal niks! Toot iets in het openbaar of volg mensen van andere servers om het te vullen",
   "follow_request.authorize": "Goedkeuren",
   "follow_request.reject": "Afkeuren",
   "getting_started.appsshort": "Apps",
   "getting_started.faq": "FAQ",
-  "getting_started.heading": "Beginnen",
+  "getting_started.heading": "Aan de slag",
   "getting_started.open_source_notice": "Mastodon is vrije software. Je kunt bijdragen of problemen melden op GitHub via {github}.",
   "getting_started.userguide": "Gebruikersgids",
   "home.column_settings.advanced": "Geavanceerd",
@@ -131,7 +133,7 @@
   "keyboard_shortcuts.favourite": "om als favoriet te markeren",
   "keyboard_shortcuts.heading": "Sneltoetsen",
   "keyboard_shortcuts.hotkey": "Sneltoets",
-  "keyboard_shortcuts.legend": "om deze legenda weer te geven",
+  "keyboard_shortcuts.legend": "om deze legenda te tonen",
   "keyboard_shortcuts.mention": "om de auteur te vermelden",
   "keyboard_shortcuts.reply": "om te reageren",
   "keyboard_shortcuts.search": "om het zoekvak te focussen",
@@ -185,9 +187,10 @@
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "In kolom tonen",
   "notifications.column_settings.sound": "Geluid afspelen",
+  "notifications.group": "{count} meldingen",
   "onboarding.done": "Klaar",
   "onboarding.next": "Volgende",
-  "onboarding.page_five.public_timelines": "De lokale tijdlijn toont openbare toots van iedereen op {domain}. De globale tijdlijn toont openbare toots van iedereen die door gebruikers van {domain} worden gevolgd, dus ook mensen van andere Mastodonservers. Dit zijn de openbare tijdlijnen en vormen een uitstekende manier om nieuwe mensen te ontdekken.",
+  "onboarding.page_five.public_timelines": "De lokale tijdlijn toont openbare toots van iedereen op {domain}. De globale tijdlijn toont openbare toots van iedereen die door gebruikers van {domain} worden gevolgd, dus ook mensen van andere Mastodonservers. Dit zijn de openbare tijdlijnen en vormen een uitstekende manier om nieuwe mensen te leren kennen.",
   "onboarding.page_four.home": "Deze tijdlijn laat toots zien van mensen die jij volgt.",
   "onboarding.page_four.notifications": "De kolom met meldingen toont alle interacties die je met andere Mastodongebruikers hebt.",
   "onboarding.page_one.federation": "Mastodon is een netwerk van onafhankelijke servers die samen een groot sociaal netwerk vormen.",
@@ -231,7 +234,7 @@
   "report.target": "Rapporteer {target}",
   "search.placeholder": "Zoeken",
   "search_popout.search_format": "Geavanceerd zoeken",
-  "search_popout.tips.full_text": "Gebruik gewone tekst om te zoeken naar jouw toots, gebooste toots, favorieten en naar toots waarin jij bent vermeldt, en naar gebruikersnamen, weergavenamen en hashtags.",
+  "search_popout.tips.full_text": "Gebruik gewone tekst om te zoeken in jouw toots, gebooste toots, favorieten en in toots waarin jij bent vermeldt, en tevens naar gebruikersnamen, weergavenamen en hashtags.",
   "search_popout.tips.hashtag": "hashtag",
   "search_popout.tips.status": "toot",
   "search_popout.tips.text": "Gebruik gewone tekst om te zoeken op weergavenamen, gebruikersnamen en hashtags",
@@ -253,7 +256,7 @@
   "status.mention": "Vermeld @{name}",
   "status.more": "Meer",
   "status.mute": "Negeer @{name}",
-  "status.mute_conversation": "Negeer conversatie",
+  "status.mute_conversation": "Negeer gesprek",
   "status.open": "Toot volledig tonen",
   "status.pin": "Aan profielpagina vastmaken",
   "status.pinned": "Vastgemaakte toot",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "Lokaal",
   "tabs_bar.notifications": "Meldingen",
   "tabs_bar.search": "Zoeken",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Je concept zal verloren gaan als je Mastodon verlaat.",
   "upload_area.title": "Hierin slepen om te uploaden",
   "upload_button.label": "Media toevoegen",
   "upload_form.description": "Omschrijf dit voor mensen met een visuele beperking",
   "upload_form.focus": "Bijsnijden",
-  "upload_form.undo": "Ongedaan maken",
+  "upload_form.undo": "Verwijderen",
   "upload_progress.label": "Uploaden...",
   "video.close": "Video sluiten",
   "video.exit_fullscreen": "Volledig scherm sluiten",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 0ee6d0722..d8de2ad4b 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokkér @{name}",
   "account.block_domain": "Skjul alt fra {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigasjon",
   "column_subheading.settings": "Innstillinger",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Denne tuten blir ikke listet under noen emneknagger da den er ulistet. Kun offentlige tuter kan søktes etter med emneknagg.",
   "compose_form.lock_disclaimer": "Din konto er ikke {locked}. Hvem som helst kan følge deg og se dine private poster.",
   "compose_form.lock_disclaimer.lock": "låst",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Fremhevet:",
   "notifications.column_settings.show": "Vis i kolonne",
   "notifications.column_settings.sound": "Spill lyd",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Ferdig",
   "onboarding.next": "Neste",
   "onboarding.page_five.public_timelines": "Den lokale tidslinjen viser offentlige poster fra alle på {domain}. Felles tidslinje viser offentlige poster fra alle som brukere på {domain} følger. Dette er de offentlige tidslinjene, et fint sted å oppdage nye brukere.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Varslinger",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Din kladd vil bli forkastet om du forlater Mastodon.",
   "upload_area.title": "Dra og slipp for å laste opp",
   "upload_button.label": "Legg til media",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index d4836e9fe..3ad5b3d95 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -1,8 +1,9 @@
 {
+  "account.badges.bot": "Robòt",
   "account.block": "Blocar @{name}",
   "account.block_domain": "Tot amagar del domeni {domain}",
   "account.blocked": "Blocat",
-  "account.direct": "Direct Message @{name}",
+  "account.direct": "Escriure un MP a @{name}",
   "account.disclaimer_full": "Aquelas informacions de perfil pòdon èsser incomplètas.",
   "account.domain_blocked": "Domeni amagat",
   "account.edit_profile": "Modificar lo perfil",
@@ -41,7 +42,7 @@
   "column.blocks": "Personas blocadas",
   "column.community": "Flux public local",
   "column.direct": "Messatges dirèctes",
-  "column.domain_blocks": "Domenis blocats",
+  "column.domain_blocks": "Domenis resconduts",
   "column.favourites": "Favorits",
   "column.follow_requests": "Demandas d’abonament",
   "column.home": "Acuèlh",
@@ -59,9 +60,10 @@
   "column_header.unpin": "Despenjar",
   "column_subheading.navigation": "Navigacion",
   "column_subheading.settings": "Paramètres",
-  "compose_form.direct_message_warning": "Aqueste tut serà pas que visibile pel monde mencionat.",
-  "compose_form.hashtag_warning": "Aqueste tut serà pas ligat a cap etiqueta estant qu’es pas listat. Òm pas cercar que los tuts publics per etiqueta.",
-  "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
+  "compose_form.direct_message_warning": "Sols los mencionats poiràn veire aqueste tut.",
+  "compose_form.direct_message_warning_learn_more": "Ne saber mai",
+  "compose_form.hashtag_warning": "Aqueste tut serà pas ligat a cap d’etiqueta estant qu’es pas listat. Òm pas cercar que los tuts publics per etiqueta.",
+  "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo monde pòt vos sègre e veire los estatuts reservats als seguidors.",
   "compose_form.lock_disclaimer.lock": "clavat",
   "compose_form.placeholder": "A de qué pensatz ?",
   "compose_form.publish": "Tut",
@@ -101,16 +103,16 @@
   "emoji_button.symbols": "Simbòls",
   "emoji_button.travel": "Viatges & lòcs",
   "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
-  "empty_column.direct": "Avètz pas encara de messatges. Quand ne mandatz un o que ne recebètz un, serà mostrat aquí.",
+  "empty_column.direct": "Avètz pas encara cap de messatges. Quand ne mandatz un o que ne recebètz un, serà mostrat aquí.",
   "empty_column.hashtag": "I a pas encara de contengut ligat a aquesta etiqueta.",
   "empty_column.home": "Vòstre flux d’acuèlh es void. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
   "empty_column.home.public_timeline": "lo flux public",
   "empty_column.list": "I a pas res dins la lista pel moment. Quand de membres d’aquesta lista publiquen de novèls estatuts los veiretz aquí.",
   "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.",
   "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public",
-  "follow_request.authorize": "Autorizar",
+  "follow_request.authorize": "Acceptar",
   "follow_request.reject": "Regetar",
-  "getting_started.appsshort": "Apps",
+  "getting_started.appsshort": "Aplicacions",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Per començar",
   "getting_started.open_source_notice": "Mastodon es un logicial liure. Podètz contribuir e mandar vòstres comentaris e rapòrt de bug via {github} sus GitHub.",
@@ -158,7 +160,7 @@
   "navigation_bar.blocks": "Personas blocadas",
   "navigation_bar.community_timeline": "Flux public local",
   "navigation_bar.direct": "Messatges dirèctes",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.domain_blocks": "Domenis resconduts",
   "navigation_bar.edit_profile": "Modificar lo perfil",
   "navigation_bar.favourites": "Favorits",
   "navigation_bar.follow_requests": "Demandas d’abonament",
@@ -185,10 +187,11 @@
   "notifications.column_settings.reblog": "Partatges :",
   "notifications.column_settings.show": "Mostrar dins la colomna",
   "notifications.column_settings.sound": "Emetre un son",
+  "notifications.group": "{count} notificacions",
   "onboarding.done": "Sortir",
   "onboarding.next": "Seguent",
   "onboarding.page_five.public_timelines": "Lo flux local mòstra los estatuts publics del monde de vòstra instància, aquí {domain}. Lo flux federat mòstra los estatuts publics de la gent que los de {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.",
-  "onboarding.page_four.home": "Lo flux d’acuèlh mòstra los estatuts del mond que seguètz.",
+  "onboarding.page_four.home": "Lo flux d’acuèlh mòstra los estatuts del monde que seguètz.",
   "onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un interagís amb vos.",
   "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per construire un malhum mai larg. Òm los apèla instàncias.",
   "onboarding.page_one.full_handle": "Vòstre escais-nom complèt",
@@ -203,7 +206,7 @@
   "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} de {domain} !",
   "onboarding.page_six.various_app": "aplicacions per mobil",
   "onboarding.page_three.profile": "Modificatz vòstre perfil per cambiar vòstre avatar, bio e escais-nom. I a enlà totas las preferéncias.",
-  "onboarding.page_three.search": "Emplegatz la barra de recèrca per trobar de mond e engachatz las etiquetas coma {illustration} e {introductions}. Per trobar una persona d’una autra instància, picatz son identificant complèt.",
+  "onboarding.page_three.search": "Emplegatz la barra de recèrca per trobar de monde e engachatz las etiquetas coma {illustration} e {introductions}. Per trobar una persona d’una autra instància, picatz son identificant complèt.",
   "onboarding.page_two.compose": "Escrivètz un estatut dempuèi la colomna per compausar. Podètz mandar un imatge, cambiar la confidencialitat e ajustar un avertiment amb las icònas cai-jos.",
   "onboarding.skip": "Passar",
   "privacy.change": "Ajustar la confidencialitat del messatge",
@@ -258,7 +261,7 @@
   "status.pin": "Penjar al perfil",
   "status.pinned": "Tut penjat",
   "status.reblog": "Partejar",
-  "status.reblog_private": "Partejar al l’audiéncia d’origina",
+  "status.reblog_private": "Partejar a l’audiéncia d’origina",
   "status.reblogged_by": "{name} a partejat",
   "status.reply": "Respondre",
   "status.replyAll": "Respondre a la conversacion",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "Flux public local",
   "tabs_bar.notifications": "Notificacions",
   "tabs_bar.search": "Recèrcas",
+  "timeline.media": "Media",
+  "timeline.posts": "Tuts",
   "ui.beforeunload": "Vòstre brolhon serà perdut se quitatz Mastodon.",
   "upload_area.title": "Lisatz e depausatz per mandar",
   "upload_button.label": "Ajustar un mèdia",
   "upload_form.description": "Descripcion pels mal vesents",
   "upload_form.focus": "Retalhar",
-  "upload_form.undo": "Anullar",
+  "upload_form.undo": "Suprimir",
   "upload_progress.label": "Mandadís…",
   "video.close": "Tampar la vidèo",
   "video.exit_fullscreen": "Sortir plen ecran",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 6d6db7c82..cca48eaf6 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokuj @{name}",
   "account.block_domain": "Blokuj wszystko z {domain}",
   "account.blocked": "Zablokowany",
@@ -63,6 +64,7 @@
   "column_subheading.navigation": "Nawigacja",
   "column_subheading.settings": "Ustawienia",
   "compose_form.direct_message_warning": "Ten wpis będzie widoczny tylko dla wszystkich wspomnianych użytkowników.",
+  "compose_form.direct_message_warning_learn_more": "Dowiedz się więcej",
   "compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
   "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje wpisy przeznaczone tylko dla śledzących.",
   "compose_form.lock_disclaimer.lock": "zablokowane",
@@ -189,6 +191,7 @@
   "notifications.column_settings.reblog": "Podbicia:",
   "notifications.column_settings.show": "Pokaż w kolumnie",
   "notifications.column_settings.sound": "Odtwarzaj dźwięk",
+  "notifications.group": "{count, number} {count, plural, one {powiadomienie} few {powiadomienia} many {powiadomień} more {powiadomień}}",
   "onboarding.done": "Gotowe",
   "onboarding.next": "Dalej",
   "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Globalna oś czasu wyświetla publiczne wpisy śledzonych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.",
@@ -281,12 +284,14 @@
   "tabs_bar.local_timeline": "Lokalne",
   "tabs_bar.notifications": "Powiadomienia",
   "tabs_bar.search": "Szukaj",
+  "timeline.media": "Zawartość multimedialna",
+  "timeline.posts": "Wpisy",
   "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.",
   "upload_area.title": "Przeciągnij i upuść aby wysłać",
   "upload_button.label": "Dodaj zawartość multimedialną",
   "upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących",
   "upload_form.focus": "Przytnij",
-  "upload_form.undo": "Cofnij",
+  "upload_form.undo": "Usuń",
   "upload_progress.label": "Wysyłanie",
   "video.close": "Zamknij film",
   "video.exit_fullscreen": "Opuść tryb pełnoekranowy",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 7f8690f91..c8441df29 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Robô",
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Esconder tudo de {domain}",
   "account.blocked": "Bloqueado",
@@ -59,7 +60,8 @@
   "column_header.unpin": "Desafixar",
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Configurações",
-  "compose_form.direct_message_warning": "Este toot só será visível a todos os usuários mencionados.",
+  "compose_form.direct_message_warning": "Este toot só será enviado aos usuários mencionados. A mensagem não é encriptada e será armazenada nos servidores dos usuários mencionados.",
+  "compose_form.direct_message_warning_learn_more": "Saber mais",
   "compose_form.hashtag_warning": "Esse toot não será listado em nenhuma hashtag por ser não listado. Somente toots públicos podem ser pesquisados por hashtag.",
   "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar postagens direcionadas a apenas seguidores.",
   "compose_form.lock_disclaimer.lock": "trancada",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Compartilhamento:",
   "notifications.column_settings.show": "Mostrar nas colunas",
   "notifications.column_settings.sound": "Reproduzir som",
+  "notifications.group": "{count} notificações",
   "onboarding.done": "Pronto",
   "onboarding.next": "Próximo",
   "onboarding.page_five.public_timelines": "A timeline local mostra postagens públicas de todos os usuários no {domain}. A timeline federada mostra todas as postagens de todas as pessoas que pessoas no {domain} seguem. Estas são as timelines públicas, uma ótima maneira de conhecer novas pessoas.",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificações",
   "tabs_bar.search": "Buscar",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Seu rascunho será perdido se você sair do Mastodon.",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar mídia",
   "upload_form.description": "Descreva a imagem para deficientes visuais",
   "upload_form.focus": "Recortar",
-  "upload_form.undo": "Desfazer",
+  "upload_form.undo": "Remover",
   "upload_progress.label": "Salvando...",
   "video.close": "Fechar vídeo",
   "video.exit_fullscreen": "Sair da tela cheia",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index ce816dc41..18d66be7f 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Esconder tudo do domínio {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Preferências",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Esta pulbicacção não será listada em nenhuma hashtag por ser não listada. Somente publicações públicas podem ser pesquisadas por hashtag.",
   "compose_form.lock_disclaimer": "A tua conta não está {locked}. Qualquer pessoa pode seguir-te e ver as publicações direcionadas apenas a seguidores.",
   "compose_form.lock_disclaimer.lock": "bloqueada",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Partilhas:",
   "notifications.column_settings.show": "Mostrar nas colunas",
   "notifications.column_settings.sound": "Reproduzir som",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Pronto",
   "onboarding.next": "Próximo",
   "onboarding.page_five.public_timelines": "A timeline local mostra as publicações de todos os utilizadores em {domain}. A timeline global mostra as publicações de todas as pessoas que pessoas em {domain} seguem. Estas são as timelines públicas, uma óptima forma de conhecer novas pessoas.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificações",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "O teu rascunho vai ser perdido se abandonares o Mastodon.",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar media",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 8eeebaf73..577ec9921 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Бот",
   "account.block": "Блокировать",
   "account.block_domain": "Блокировать все с {domain}",
   "account.blocked": "Заблокирован(а)",
@@ -40,7 +41,7 @@
   "bundle_modal_error.retry": "Попробовать снова",
   "column.blocks": "Список блокировки",
   "column.community": "Локальная лента",
-  "column.direct": "Direct messages",
+  "column.direct": "Личные сообщения",
   "column.domain_blocks": "Скрытые домены",
   "column.favourites": "Понравившееся",
   "column.follow_requests": "Запросы на подписку",
@@ -59,7 +60,8 @@
   "column_header.unpin": "Открепить",
   "column_subheading.navigation": "Навигация",
   "column_subheading.settings": "Настройки",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Этот статус будет виден только упомянутым пользователям.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Этот пост не будет показывается в поиске по хэштегу, т.к. он непубличный. Только публичные посты можно найти в поиске по хэштегу.",
   "compose_form.lock_disclaimer": "Ваш аккаунт не {locked}. Любой человек может подписаться на Вас и просматривать посты для подписчиков.",
   "compose_form.lock_disclaimer.lock": "закрыт",
@@ -101,7 +103,7 @@
   "emoji_button.symbols": "Символы",
   "emoji_button.travel": "Путешествия",
   "empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "У Вас пока нет личных сообщений. Когда Вы начнёте их отправлять или получать, они появятся здесь.",
   "empty_column.hashtag": "Статусов с таким хэштегом еще не существует.",
   "empty_column.home": "Пока Вы ни на кого не подписаны. Полистайте {public} или используйте поиск, чтобы освоиться и завести новые знакомства.",
   "empty_column.home.public_timeline": "публичные ленты",
@@ -135,7 +137,7 @@
   "keyboard_shortcuts.mention": "упомянуть автора поста",
   "keyboard_shortcuts.reply": "ответить",
   "keyboard_shortcuts.search": "перейти к поиску",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "показать/скрыть текст за предупреждением",
   "keyboard_shortcuts.toot": "начать писать новый пост",
   "keyboard_shortcuts.unfocus": "убрать фокус с поля ввода/поиска",
   "keyboard_shortcuts.up": "вверх по списку",
@@ -157,7 +159,7 @@
   "mute_modal.hide_notifications": "Убрать уведомления от этого пользователя?",
   "navigation_bar.blocks": "Список блокировки",
   "navigation_bar.community_timeline": "Локальная лента",
-  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.direct": "Личные сообщения",
   "navigation_bar.domain_blocks": "Скрытые домены",
   "navigation_bar.edit_profile": "Изменить профиль",
   "navigation_bar.favourites": "Понравившееся",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Продвижения:",
   "notifications.column_settings.show": "Показывать в колонке",
   "notifications.column_settings.sound": "Проигрывать звук",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Готово",
   "onboarding.next": "Далее",
   "onboarding.page_five.public_timelines": "Локальная лента показывает публичные посты всех пользователей {domain}. Глобальная лента показывает публичные посты всех людей, на которых подписаны пользователи {domain}. Это - публичные ленты, отличный способ найти новые знакомства.",
@@ -223,12 +226,12 @@
   "relative_time.minutes": "{number}м",
   "relative_time.seconds": "{number}с",
   "reply_indicator.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.forward": "Переслать для {target}",
+  "report.forward_hint": "Этот аккаунт расположен на другом сервере. Отправить туда анонимную копию Вашей жалобы?",
   "report.hint": "Жалоба будет отправлена модераторам Вашего сервера. Вы также можете указать подробную причину жалобы ниже:",
   "report.placeholder": "Комментарий",
   "report.submit": "Отправить",
-  "report.target": "Жалуемся на",
+  "report.target": "Жалуемся на {target}",
   "search.placeholder": "Поиск",
   "search_popout.search_format": "Продвинутый формат поиска",
   "search_popout.tips.full_text": "Возвращает посты, которые Вы написали, отметили как 'избранное', продвинули или в которых были упомянуты, а также содержащие юзернейм, имя и хэштеги.",
@@ -236,16 +239,16 @@
   "search_popout.tips.status": "статус",
   "search_popout.tips.text": "Простой ввод текста покажет совпадающие имена пользователей, отображаемые имена и хэштеги",
   "search_popout.tips.user": "пользователь",
-  "search_results.accounts": "People",
-  "search_results.hashtags": "Hashtags",
-  "search_results.statuses": "Toots",
+  "search_results.accounts": "Люди",
+  "search_results.hashtags": "Хэштеги",
+  "search_results.statuses": "Посты",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
   "standalone.public_title": "Прямо сейчас",
   "status.block": "Заблокировать @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Не продвигать",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
   "status.delete": "Удалить",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Написать @{name}",
   "status.embed": "Встроить",
   "status.favourite": "Нравится",
   "status.load_more": "Показать еще",
@@ -256,9 +259,9 @@
   "status.mute_conversation": "Заглушить тред",
   "status.open": "Развернуть статус",
   "status.pin": "Закрепить в профиле",
-  "status.pinned": "Pinned toot",
+  "status.pinned": "Закреплённый статус",
   "status.reblog": "Продвинуть",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Продвинуть для своей аудитории",
   "status.reblogged_by": "{name} продвинул(а)",
   "status.reply": "Ответить",
   "status.replyAll": "Ответить на тред",
@@ -267,21 +270,23 @@
   "status.sensitive_warning": "Чувствительный контент",
   "status.share": "Поделиться",
   "status.show_less": "Свернуть",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "Свернуть для всех",
   "status.show_more": "Развернуть",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "Развернуть для всех",
   "status.unmute_conversation": "Снять глушение с треда",
   "status.unpin": "Открепить от профиля",
   "tabs_bar.federated_timeline": "Глобальная",
   "tabs_bar.home": "Главная",
   "tabs_bar.local_timeline": "Локальная",
   "tabs_bar.notifications": "Уведомления",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Поиск",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Ваш черновик будет утерян, если вы покинете Mastodon.",
   "upload_area.title": "Перетащите сюда, чтобы загрузить",
   "upload_button.label": "Добавить медиаконтент",
   "upload_form.description": "Описать для людей с нарушениями зрения",
-  "upload_form.focus": "Crop",
+  "upload_form.focus": "Обрезать",
   "upload_form.undo": "Отменить",
   "upload_progress.label": "Загрузка...",
   "video.close": "Закрыть видео",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index e5e826c96..b85ba1318 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokovať @{name}",
   "account.block_domain": "Ukryť všetko z {domain}",
   "account.blocked": "Blokovaný/á",
@@ -59,7 +60,8 @@
   "column_header.unpin": "Odopnúť",
   "column_subheading.navigation": "Navigácia",
   "column_subheading.settings": "Nastavenia",
-  "compose_form.direct_message_warning": "Tento príspevok bude videný výhradne iba spomenutými užívateľmi.",
+  "compose_form.direct_message_warning": "Tento príspevok bude videný výhradne iba spomenutými užívateľmi. Ber ale na vedomie že správci tvojej a všetkých iných zahrnutých instancií majú možnosť skontrolovať túto správu.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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.",
   "compose_form.lock_disclaimer": "Váš účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
   "compose_form.lock_disclaimer.lock": "zamknutý",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Boosty:",
   "notifications.column_settings.show": "Zobraziť v stĺpci",
   "notifications.column_settings.sound": "Prehrať zvuk",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Koniec",
   "onboarding.next": "Ďalej",
   "onboarding.page_five.public_timelines": "Lokálna časová os zobrazuje verejné správy od všetkých na {domain}. Federovaná časová os zobrazuje verejné správy od všetkých tých, čo následujú užívatrľov {domain} z iných serverov. Tieto sú takzvané Verejné Časové Osi, výborná možnosť ako nájsť a spoznať nových ľudí.",
@@ -231,7 +234,7 @@
   "report.target": "Nahlásenie {target}",
   "search.placeholder": "Hľadaj",
   "search_popout.search_format": "Pokročilé vyhľadávanie",
-  "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.full_text": "Jednoduchý textový výpis statusov 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": "status",
   "search_popout.tips.text": "Jednoduchý text vráti zhodujúce sa mená, prezývky a hashtagy",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "Lokálna",
   "tabs_bar.notifications": "Notifikácie",
   "tabs_bar.search": "Hľadaj",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Čo máte rozpísané sa stratí, ak opustíte Mastodon.",
   "upload_area.title": "Ťahaj a pusti pre nahratie",
   "upload_button.label": "Pridať médiá",
   "upload_form.description": "Opis pre slabo vidiacich",
   "upload_form.focus": "Vystrihni",
-  "upload_form.undo": "Navrátiť",
+  "upload_form.undo": "Vymaž",
   "upload_progress.label": "Nahráva sa...",
   "video.close": "Zavrieť video",
   "video.exit_fullscreen": "Vpnúť zobrazenie na celú obrazovku",
diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json
new file mode 100644
index 000000000..3cc7389ae
--- /dev/null
+++ b/app/javascript/mastodon/locales/sl.json
@@ -0,0 +1,301 @@
+{
+  "account.badges.bot": "Robot",
+  "account.block": "Blokiraj @{name}",
+  "account.block_domain": "Skrij vse iz {domain}",
+  "account.blocked": "Blokirano",
+  "account.direct": "Neposredno sporočilo @{name}",
+  "account.disclaimer_full": "Spodnje informacije lahko nepopolno prikazujejo profil uporabnika.",
+  "account.domain_blocked": "Skrita domena",
+  "account.edit_profile": "Uredi profil",
+  "account.follow": "Sledi",
+  "account.followers": "Sledilci",
+  "account.follows": "Sledi",
+  "account.follows_you": "Ti sledi",
+  "account.hide_reblogs": "Skrij napuhke od @{name}",
+  "account.media": "Mediji",
+  "account.mention": "Omeni @{name}",
+  "account.moved_to": "{name} se je premaknil na:",
+  "account.mute": "Utišaj @{name}",
+  "account.mute_notifications": "Utišaj obvestila od @{name}",
+  "account.muted": "Utišan",
+  "account.posts": "Tuti",
+  "account.posts_with_replies": "Tuti in odgovori",
+  "account.report": "Prijavi @{name}",
+  "account.requested": "Čakanje na odobritev. Kliknite, da prekličete prošnjo za sledenje",
+  "account.share": "Delite profil osebe @{name}",
+  "account.show_reblogs": "Pokaži delitve osebe @{name}",
+  "account.unblock": "Odblokiraj @{name}",
+  "account.unblock_domain": "Razkrij {domain}",
+  "account.unfollow": "Prenehaj slediti",
+  "account.unmute": "Odtišaj @{name}",
+  "account.unmute_notifications": "Vklopi obvestila od @{name}",
+  "account.view_full_profile": "Ogled celotnega profila",
+  "alert.unexpected.message": "Zgodila se je nepričakovana napaka.",
+  "alert.unexpected.title": "Uups!",
+  "boost_modal.combo": "Če želite naslednjič preskočiti to, lahko pritisnete {combo}",
+  "bundle_column_error.body": "Med nalaganjem te komponente je prišlo do napake.",
+  "bundle_column_error.retry": "Poskusi ponovno",
+  "bundle_column_error.title": "Napaka omrežja",
+  "bundle_modal_error.close": "Zapri",
+  "bundle_modal_error.message": "Med nalaganjem te komponente je prišlo do napake.",
+  "bundle_modal_error.retry": "Poskusi ponovno",
+  "column.blocks": "Blokirani uporabniki",
+  "column.community": "Lokalna časovnica",
+  "column.direct": "Neposredna sporočila",
+  "column.domain_blocks": "Skrite domene",
+  "column.favourites": "Priljubljene",
+  "column.follow_requests": "Sledi prošnjam",
+  "column.home": "Domov",
+  "column.lists": "Seznami",
+  "column.mutes": "Utišani uporabniki",
+  "column.notifications": "Obvestila",
+  "column.pins": "Pripeti tuti",
+  "column.public": "Združena časovnica",
+  "column_back_button.label": "Nazaj",
+  "column_header.hide_settings": "Skrij nastavitve",
+  "column_header.moveLeft_settings": "Premakni stolpec na levo",
+  "column_header.moveRight_settings": "Premakni stolpec na desno",
+  "column_header.pin": "Pripni",
+  "column_header.show_settings": "Prikaži nastavitve",
+  "column_header.unpin": "Odpni",
+  "column_subheading.navigation": "Navigacija",
+  "column_subheading.settings": "Nastavitve",
+  "compose_form.direct_message_warning": "Ta tut bo viden le vsem omenjenim uporabnikom.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
+  "compose_form.hashtag_warning": "Ta tut ne bo naveden pod nobenim hashtagom, ker ni dodan hashtag. Samo javne tute lahko iščete pod hashtagom.",
+  "compose_form.lock_disclaimer": "Vaš račun ni {locked}. Vsakdo vam lahko sledi in si ogleda objave, ki so namenjene samo sledilcem.",
+  "compose_form.lock_disclaimer.lock": "zaklenjen",
+  "compose_form.placeholder": "O čem razmišljaš?",
+  "compose_form.publish": "Tutni",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.marked": "Medij je označen kot občutljiv",
+  "compose_form.sensitive.unmarked": "Medij ni označen kot občutljiv",
+  "compose_form.spoiler.marked": "Besedilo je skrito za opozorilom",
+  "compose_form.spoiler.unmarked": "Besedilo ni skrito",
+  "compose_form.spoiler_placeholder": "Napišite opozorilo tukaj",
+  "confirmation_modal.cancel": "Prekliči",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Ali ste prepričani, da želite blokirati {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Ali ste prepričani, da želite izbrisati to stanje?",
+  "confirmations.delete_list.confirm": "Delete",
+  "confirmations.delete_list.message": "Ali ste prepričani, da želite trajno izbrisati ta seznam?",
+  "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.",
+  "confirmations.mute.confirm": "Utišanje",
+  "confirmations.mute.message": "Ali ste prepričani, da želite utišati {name}?",
+  "confirmations.unfollow.confirm": "Prenehaj slediti",
+  "confirmations.unfollow.message": "Ali ste prepričani, da ne želite več slediti {name}?",
+  "embed.instructions": "Vstavi ta status na svojo spletno stran tako, da kopirate spodnjo kodo.",
+  "embed.preview": "Tukaj je, kako bo izgledalo:",
+  "emoji_button.activity": "Dejavnost",
+  "emoji_button.custom": "Po meri",
+  "emoji_button.flags": "Zastave",
+  "emoji_button.food": "Hrana in Pijača",
+  "emoji_button.label": "Vstavi emojija",
+  "emoji_button.nature": "Narava",
+  "emoji_button.not_found": "Ni emojijev!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Predmeti",
+  "emoji_button.people": "Ljudje",
+  "emoji_button.recent": "Pogosto uporabljeni",
+  "emoji_button.search": "Poišči...",
+  "emoji_button.search_results": "Rezultati iskanja",
+  "emoji_button.symbols": "Simboli",
+  "emoji_button.travel": "Potovanja in Kraji",
+  "empty_column.community": "Lokalna časovnica je prazna. Napišite nekaj javnega, da se bo žoga zakotalila!",
+  "empty_column.direct": "Nimate še nobenih neposrednih sporočil. Ko ga pošljete ali prejmete, se prikaže tukaj.",
+  "empty_column.hashtag": "V tem hashtagu še ni nič.",
+  "empty_column.home": "Vaša domača časovnica je prazna! Obiščite {public} ali uporabite iskanje, da se boste srečali druge uporabnike.",
+  "empty_column.home.public_timeline": "javna časovnica",
+  "empty_column.list": "Na tem seznamu ni ničesar. Ko bodo člani tega seznama objavili nove statuse, se bodo pojavili tukaj.",
+  "empty_column.notifications": "Nimate še nobenih obvestil. Poveži se z drugimi, da začnete pogovor.",
+  "empty_column.public": "Tukaj ni ničesar! Da ga napolnite, napišite nekaj javnega ali pa ročno sledite uporabnikom iz drugih vozlišč",
+  "follow_request.authorize": "Odobri",
+  "follow_request.reject": "Zavrni",
+  "getting_started.appsshort": "Aplikacije",
+  "getting_started.faq": "FAQ",
+  "getting_started.heading": "Prvi koraki",
+  "getting_started.open_source_notice": "Mastodon je odprtokodna programska oprema. V GitHubu na {github} lahko prispevate ali poročate o napakah.",
+  "getting_started.userguide": "Navodila za uporabo",
+  "home.column_settings.advanced": "Napredno",
+  "home.column_settings.basic": "Osnovno",
+  "home.column_settings.filter_regex": "Filtrirajte z navadnimi izrazi",
+  "home.column_settings.show_reblogs": "Pokaži sunke",
+  "home.column_settings.show_replies": "Pokaži odgovore",
+  "home.settings": "Nastavitve stolpcev",
+  "keyboard_shortcuts.back": "za krmarjenje nazaj",
+  "keyboard_shortcuts.boost": "suniti",
+  "keyboard_shortcuts.column": "osredotočiti status v enega od stolpcev",
+  "keyboard_shortcuts.compose": "osredotočiti na sestavljanje besedila",
+  "keyboard_shortcuts.description": "Opis",
+  "keyboard_shortcuts.down": "premakniti navzdol po seznamu",
+  "keyboard_shortcuts.enter": "odpreti status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Tipkovne bližnjice",
+  "keyboard_shortcuts.hotkey": "Hitra tipka",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toot": "da začnete povsem nov tut",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
+  "lightbox.close": "Close",
+  "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.new.create": "Add list",
+  "lists.new.title_placeholder": "New list title",
+  "lists.search": "Search among people you follow",
+  "lists.subheading": "Your lists",
+  "loading_indicator.label": "Loading...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "missing_indicator.sublabel": "This resource could not be found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.edit_profile": "Edit profile",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "O tem vozlišču",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.lists": "Lists",
+  "navigation_bar.logout": "Logout",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pripeti tuti",
+  "navigation_bar.preferences": "Preferences",
+  "navigation_bar.public_timeline": "Federated timeline",
+  "notification.favourite": "{name} favourited your status",
+  "notification.follow": "{name} followed you",
+  "notification.mention": "{name} mentioned you",
+  "notification.reblog": "{name} boosted your status",
+  "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.follow": "New followers:",
+  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Show in column",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.group": "{count} notifications",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.full_handle": "Your full handle",
+  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetut!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "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",
+  "regeneration_indicator.label": "Loading…",
+  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "relative_time.days": "{number}d",
+  "relative_time.hours": "{number}h",
+  "relative_time.just_now": "now",
+  "relative_time.minutes": "{number}m",
+  "relative_time.seconds": "{number}s",
+  "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": "Tuti",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
+  "status.cancel_reblog_private": "Unboost",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Delete",
+  "status.direct": "Direct message @{name}",
+  "status.embed": "Embed",
+  "status.favourite": "Favourite",
+  "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": "Pripeti tut",
+  "status.reblog": "Suni",
+  "status.reblog_private": "Suni v prvotno občinstvo",
+  "status.reblogged_by": "{name} sunjen",
+  "status.reply": "Odgovori",
+  "status.replyAll": "Odgovori na objavo",
+  "status.report": "Prijavi @{name}",
+  "status.sensitive_toggle": "Kliknite za ogled",
+  "status.sensitive_warning": "Občutljiva vsebina",
+  "status.share": "Deli",
+  "status.show_less": "Prikaži manj",
+  "status.show_less_all": "Prikaži manj za vse",
+  "status.show_more": "Prikaži več",
+  "status.show_more_all": "Prikaži več za vse",
+  "status.unmute_conversation": "Odtišaj pogovor",
+  "status.unpin": "Odpni iz profila",
+  "tabs_bar.federated_timeline": "Združeno",
+  "tabs_bar.home": "Domov",
+  "tabs_bar.local_timeline": "Lokalno",
+  "tabs_bar.notifications": "Obvestila",
+  "tabs_bar.search": "Poišči",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
+  "ui.beforeunload": "Vaš osnutek bo izgubljen, če zapustite Mastodona.",
+  "upload_area.title": "Povlecite in spustite za pošiljanje",
+  "upload_button.label": "Dodaj medij",
+  "upload_form.description": "Opišite za slabovidne",
+  "upload_form.focus": "Obreži",
+  "upload_form.undo": "Izbriši",
+  "upload_progress.label": "Pošiljanje...",
+  "video.close": "Zapri video",
+  "video.exit_fullscreen": "Izhod iz celozaslonskega načina",
+  "video.expand": "Razširi video",
+  "video.fullscreen": "Celozaslonski način",
+  "video.hide": "Skrij video",
+  "video.mute": "Utišaj zvok",
+  "video.pause": "Premor",
+  "video.play": "Predvajaj",
+  "video.unmute": "Vklopi zvok"
+}
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index b1ea0d179..e2b55c179 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blokiraj korisnika @{name}",
   "account.block_domain": "Sakrij sve sa domena {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigacija",
   "column_subheading.settings": "Postavke",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Vaš nalog nije {locked}. Svako može da Vas zaprati i da vidi objave namenjene samo Vašim pratiocima.",
   "compose_form.lock_disclaimer.lock": "zaključan",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Podrški:",
   "notifications.column_settings.show": "Prikaži u koloni",
   "notifications.column_settings.sound": "Puštaj zvuk",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Gotovo",
   "onboarding.next": "Sledeće",
   "onboarding.page_five.public_timelines": "Lokalna lajna prikazuje sve javne statuse od svih na domenu {domain}. Federisana lajna prikazuje javne statuse od svih ljudi koje prate korisnici sa domena {domain}. Ovo su javne lajne, sjajan način da otkrijete nove ljude.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Lokalno",
   "tabs_bar.notifications": "Obaveštenja",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Ako napustite Mastodont, izgubićete napisani nacrt.",
   "upload_area.title": "Prevucite ovde da otpremite",
   "upload_button.label": "Dodaj multimediju",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index aa978675f..09efb985c 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Блокирај корисника @{name}",
   "account.block_domain": "Сакриј све са домена {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Навигација",
   "column_subheading.settings": "Поставке",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Ваш налог није {locked}. Свако може да Вас запрати и да види објаве намењене само Вашим пратиоцима.",
   "compose_form.lock_disclaimer.lock": "закључан",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Подршки:",
   "notifications.column_settings.show": "Прикажи у колони",
   "notifications.column_settings.sound": "Пуштај звук",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Готово",
   "onboarding.next": "Следеће",
   "onboarding.page_five.public_timelines": "Локална лајна приказује све јавне статусе од свих на домену {domain}. Федерисана лајна приказује јавне статусе од свих људи које прате корисници са домена {domain}. Ово су јавне лајне, сјајан начин да откријете нове људе.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Локално",
   "tabs_bar.notifications": "Обавештења",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Ако напустите Мастодонт, изгубићете написани нацрт.",
   "upload_area.title": "Превуците овде да отпремите",
   "upload_button.label": "Додај мултимедију",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index 4efe88a7e..d2af13bfb 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Blockera @{name}",
   "account.block_domain": "Dölj allt från {domain}",
   "account.blocked": "Blockerad",
@@ -59,7 +60,8 @@
   "column_header.unpin": "Ångra fäst",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Inställningar",
-  "compose_form.direct_message_warning": "Denna toot kommer endast vara synlig för nämnda användare.",
+  "compose_form.direct_message_warning": "Denna toot kommer endast att skickas nämnda nämnda användare.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "Denna toot kommer inte att listas under någon hashtag eftersom den är onoterad. Endast offentliga toots kan sökas med hashtag.",
   "compose_form.lock_disclaimer": "Ditt konto är inte {locked}. Vemsomhelst kan följa dig och även se dina inlägg skrivna för endast dina följare.",
   "compose_form.lock_disclaimer.lock": "låst",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Knuffar:",
   "notifications.column_settings.show": "Visa i kolumnen",
   "notifications.column_settings.sound": "Spela upp ljud",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Klart",
   "onboarding.next": "Nästa",
   "onboarding.page_five.public_timelines": "Den lokala tidslinjen visar offentliga inlägg från alla på {domain}. Den förenade tidslinjen visar offentliga inlägg från alla personer på {domain} som följer. Dom här offentliga tidslinjerna är ett bra sätt att upptäcka nya människor.",
@@ -277,17 +280,19 @@
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Meddelanden",
   "tabs_bar.search": "Sök",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Ditt utkast kommer att förloras om du lämnar Mastodon.",
   "upload_area.title": "Dra & släpp för att ladda upp",
   "upload_button.label": "Lägg till media",
   "upload_form.description": "Beskriv för synskadade",
   "upload_form.focus": "Beskär",
-  "upload_form.undo": "Ångra",
+  "upload_form.undo": "Ta bort",
   "upload_progress.label": "Laddar upp...",
   "video.close": "Stäng video",
   "video.exit_fullscreen": "Stäng helskärm",
   "video.expand": "Expandera video",
-  "video.fullscreen": "Helskärm",
+  "video.fullscreen": "Fullskärm",
   "video.hide": "Dölj video",
   "video.mute": "Stäng av ljud",
   "video.pause": "Pause",
diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json
index a56720fee..ce0d4b9cc 100644
--- a/app/javascript/mastodon/locales/te.json
+++ b/app/javascript/mastodon/locales/te.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Done",
   "onboarding.next": "Next",
   "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notifications",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 82b44fe30..f0ba25ec3 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Done",
   "onboarding.next": "Next",
   "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notifications",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 056fbfe8f..89f00fd3b 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Engelle @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Navigasyon",
   "column_subheading.settings": "Ayarlar",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "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.lock_disclaimer.lock": "kilitli",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Boost’lar:",
   "notifications.column_settings.show": "Bildirimlerde göster",
   "notifications.column_settings.sound": "Ses çal",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Tamam",
   "onboarding.next": "Sıradaki",
   "onboarding.page_five.public_timelines": "Yerel zaman tüneli, bu sunucudaki herkesten gelen gönderileri gösterir.Federe zaman tüneli, kullanıcıların diğer sunuculardan takip ettiği kişilerin herkese açık gönderilerini gösterir. Bunlar herkese açık zaman tünelleridir ve yeni insanlarla tanışmak  için harika yerlerdir. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new ",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Yerel",
   "tabs_bar.notifications": "Bildirimler",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Upload için sürükle bırak yapınız",
   "upload_button.label": "Görsel ekle",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 1a7b58789..e81476541 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "Заблокувати",
   "account.block_domain": "Заглушити {domain}",
   "account.blocked": "Blocked",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "Навігація",
   "column_subheading.settings": "Налаштування",
   "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "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": "Ваш акаунт не {locked}. Кожен може підписатися на Вас та бачити Ваші приватні пости.",
   "compose_form.lock_disclaimer.lock": "приватний",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "Передмухи:",
   "notifications.column_settings.show": "Показати в колонці",
   "notifications.column_settings.sound": "Відтворювати звук",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "Готово",
   "onboarding.next": "Далі",
   "onboarding.page_five.public_timelines": "Локальна стрічка показує публічні пости усіх користувачів {domain}. Глобальна стрічка показує публічні пости усіх людей, на яких підписані користувачі {domain}. Це публичні стрічки, відмінний спосіб знайти нових людей.",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "Локальна",
   "tabs_bar.notifications": "Сповіщення",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Перетягніть сюди, щоб завантажити",
   "upload_button.label": "Додати медіаконтент",
diff --git a/app/javascript/mastodon/locales/whitelist_co.json b/app/javascript/mastodon/locales/whitelist_co.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_co.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/whitelist_sl.json b/app/javascript/mastodon/locales/whitelist_sl.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_sl.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index a3a4de0af..b67afd29f 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -1,10 +1,11 @@
 {
+  "account.badges.bot": "机器人",
   "account.block": "屏蔽 @{name}",
   "account.block_domain": "隐藏来自 {domain} 的内容",
-  "account.blocked": "Blocked",
-  "account.direct": "Direct Message @{name}",
+  "account.blocked": "已屏蔽",
+  "account.direct": "发送私信给 @{name}",
   "account.disclaimer_full": "此处显示的信息可能不是全部内容。",
-  "account.domain_blocked": "Domain hidden",
+  "account.domain_blocked": "网站已屏蔽",
   "account.edit_profile": "修改个人资料",
   "account.follow": "关注",
   "account.followers": "关注者",
@@ -16,9 +17,9 @@
   "account.moved_to": "{name} 已经迁移到:",
   "account.mute": "隐藏 @{name}",
   "account.mute_notifications": "隐藏来自 @{name} 的通知",
-  "account.muted": "Muted",
+  "account.muted": "已隐藏",
   "account.posts": "嘟文",
-  "account.posts_with_replies": "Toots with replies",
+  "account.posts_with_replies": "嘟文和回复",
   "account.report": "举报 @{name}",
   "account.requested": "正在等待对方同意。点击以取消发送关注请求",
   "account.share": "分享 @{name} 的个人资料",
@@ -29,8 +30,8 @@
   "account.unmute": "不再隐藏 @{name}",
   "account.unmute_notifications": "不再隐藏来自 @{name} 的通知",
   "account.view_full_profile": "查看完整资料",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "发生了意外错误。",
+  "alert.unexpected.title": "哎呀!",
   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
   "bundle_column_error.body": "载入这个组件时发生了错误。",
   "bundle_column_error.retry": "重试",
@@ -38,15 +39,15 @@
   "bundle_modal_error.close": "关闭",
   "bundle_modal_error.message": "载入这个组件时发生了错误。",
   "bundle_modal_error.retry": "重试",
-  "column.blocks": "屏蔽用户",
+  "column.blocks": "已屏蔽的用户",
   "column.community": "本站时间轴",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "私信",
+  "column.domain_blocks": "已屏蔽的网站",
   "column.favourites": "收藏过的嘟文",
   "column.follow_requests": "关注请求",
   "column.home": "主页",
   "column.lists": "列表",
-  "column.mutes": "被隐藏的用户",
+  "column.mutes": "已隐藏的用户",
   "column.notifications": "通知",
   "column.pins": "置顶嘟文",
   "column.public": "跨站公共时间轴",
@@ -59,17 +60,18 @@
   "column_header.unpin": "取消固定",
   "column_subheading.navigation": "导航",
   "column_subheading.settings": "设置",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "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.placeholder": "在想啥?",
   "compose_form.publish": "嘟嘟",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.sensitive.marked": "媒体已被标记为敏感内容",
+  "compose_form.sensitive.unmarked": "媒体未被标记为敏感内容",
+  "compose_form.spoiler.marked": "正文已被折叠在警告信息之后",
+  "compose_form.spoiler.unmarked": "正文未被折叠",
   "compose_form.spoiler_placeholder": "折叠部分的警告消息",
   "confirmation_modal.cancel": "取消",
   "confirmations.block.confirm": "屏蔽",
@@ -101,7 +103,7 @@
   "emoji_button.symbols": "符号",
   "emoji_button.travel": "旅行和地点",
   "empty_column.community": "本站时间轴暂时没有内容,快嘟几个来抢头香啊!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "你还没有使用过私信。当你发出或者收到私信时,它会在这里显示。",
   "empty_column.hashtag": "这个话题标签下暂时没有内容。",
   "empty_column.home": "你还没有关注任何用户。快看看{public},向其他用户搭讪吧。",
   "empty_column.home.public_timeline": "公共时间轴",
@@ -135,7 +137,7 @@
   "keyboard_shortcuts.mention": "提及嘟文作者",
   "keyboard_shortcuts.reply": "回复嘟文",
   "keyboard_shortcuts.search": "选择搜索框",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "显示或隐藏被折叠的正文",
   "keyboard_shortcuts.toot": "发送新嘟文",
   "keyboard_shortcuts.unfocus": "取消输入",
   "keyboard_shortcuts.up": "在列表中让光标上移",
@@ -155,10 +157,10 @@
   "missing_indicator.label": "找不到内容",
   "missing_indicator.sublabel": "无法找到此资源",
   "mute_modal.hide_notifications": "同时隐藏来自这个用户的通知",
-  "navigation_bar.blocks": "被屏蔽的用户",
+  "navigation_bar.blocks": "已屏蔽的用户",
   "navigation_bar.community_timeline": "本站时间轴",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "私信",
+  "navigation_bar.domain_blocks": "已屏蔽的网站",
   "navigation_bar.edit_profile": "修改个人资料",
   "navigation_bar.favourites": "收藏的内容",
   "navigation_bar.follow_requests": "关注请求",
@@ -166,7 +168,7 @@
   "navigation_bar.keyboard_shortcuts": "快捷键列表",
   "navigation_bar.lists": "列表",
   "navigation_bar.logout": "注销",
-  "navigation_bar.mutes": "被隐藏的用户",
+  "navigation_bar.mutes": "已隐藏的用户",
   "navigation_bar.pins": "置顶嘟文",
   "navigation_bar.preferences": "首选项",
   "navigation_bar.public_timeline": "跨站公共时间轴",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "当有人转嘟了你的嘟文时:",
   "notifications.column_settings.show": "在通知栏显示",
   "notifications.column_settings.sound": "播放音效",
+  "notifications.group": "{count} 条通知",
   "onboarding.done": "出发!",
   "onboarding.next": "下一步",
   "onboarding.page_five.public_timelines": "“本站时间轴”显示的是由本站({domain})用户发布的所有公开嘟文。“跨站公共时间轴”显示的的是由本站用户关注对象所发布的所有公开嘟文。这些就是寻人好去处的公共时间轴啦。",
@@ -223,29 +226,29 @@
   "relative_time.minutes": "{number}分",
   "relative_time.seconds": "{number}秒",
   "reply_indicator.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.forward": "发送举报至 {target}",
+  "report.forward_hint": "这名用户来自另一个实例。是否要向那个实例发送一条匿名的举报?",
+  "report.hint": "举报将会发送给你所在实例的监察员。你可以在下面填写举报这个用户的理由:",
   "report.placeholder": "附言",
   "report.submit": "提交",
   "report.target": "举报 {target}",
   "search.placeholder": "搜索",
   "search_popout.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.full_text": "输入其他内容将会返回所有你撰写、收藏、转嘟过或提及到你的嘟文,同时也会在用户名、昵称和话题标签中进行搜索。",
   "search_popout.tips.hashtag": "话题标签",
   "search_popout.tips.status": "嘟文",
-  "search_popout.tips.text": "使用普通字符进行搜索将会返回昵称、用户名和话题标签",
+  "search_popout.tips.text": "输入其他内容将会返回昵称、用户名和话题标签",
   "search_popout.tips.user": "用户",
-  "search_results.accounts": "People",
-  "search_results.hashtags": "Hashtags",
-  "search_results.statuses": "Toots",
+  "search_results.accounts": "用户",
+  "search_results.hashtags": "话题标签",
+  "search_results.statuses": "嘟文",
   "search_results.total": "共 {count, number} 个结果",
   "standalone.public_title": "大家都在干啥?",
   "status.block": "屏蔽 @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "取消转嘟",
   "status.cannot_reblog": "无法转嘟这条嘟文",
   "status.delete": "删除",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "发送私信给 @{name}",
   "status.embed": "嵌入",
   "status.favourite": "收藏",
   "status.load_more": "加载更多",
@@ -256,9 +259,9 @@
   "status.mute_conversation": "隐藏此对话",
   "status.open": "展开嘟文",
   "status.pin": "在个人资料页面置顶",
-  "status.pinned": "Pinned toot",
+  "status.pinned": "置顶嘟文",
   "status.reblog": "转嘟",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "转嘟给原有关注者",
   "status.reblogged_by": "{name} 转嘟了",
   "status.reply": "回复",
   "status.replyAll": "回复所有人",
@@ -267,21 +270,23 @@
   "status.sensitive_warning": "敏感内容",
   "status.share": "分享",
   "status.show_less": "隐藏内容",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "隐藏所有内容",
   "status.show_more": "显示内容",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "显示所有内容",
   "status.unmute_conversation": "不再隐藏此对话",
   "status.unpin": "在个人资料页面取消置顶",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主页",
   "tabs_bar.local_timeline": "本站",
   "tabs_bar.notifications": "通知",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "搜索",
+  "timeline.media": "媒体",
+  "timeline.posts": "嘟文",
   "ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会被丢弃。",
   "upload_area.title": "将文件拖放到此处开始上传",
   "upload_button.label": "上传媒体文件",
   "upload_form.description": "为视觉障碍人士添加文字说明",
-  "upload_form.focus": "Crop",
+  "upload_form.focus": "剪裁",
   "upload_form.undo": "取消上传",
   "upload_progress.label": "上传中…",
   "video.close": "关闭视频",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 7719e08a6..cad53c69a 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "機械人",
   "account.block": "封鎖 @{name}",
   "account.block_domain": "隱藏來自 {domain} 的一切文章",
   "account.blocked": "封鎖",
@@ -40,7 +41,7 @@
   "bundle_modal_error.retry": "重試",
   "column.blocks": "封鎖用戶",
   "column.community": "本站時間軸",
-  "column.direct": "Direct messages",
+  "column.direct": "個人訊息",
   "column.domain_blocks": "隱藏的服務站",
   "column.favourites": "最愛的文章",
   "column.follow_requests": "關注請求",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
   "compose_form.direct_message_warning": "這文章只有被提及的用戶才可以看到。",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "這文章因為不是公開,所以不會被標籤搜索。只有公開的文章才會被標籤搜索。",
   "compose_form.lock_disclaimer": "你的用戶狀態為「{locked}」,任何人都能立即關注你,然後看到「只有關注者能看」的文章。",
   "compose_form.lock_disclaimer.lock": "公共",
@@ -101,7 +103,7 @@
   "emoji_button.symbols": "符號",
   "emoji_button.travel": "旅遊景物",
   "empty_column.community": "本站時間軸暫時未有內容,快寫一點東西來搶頭香啊!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "你沒有個人訊息。當你發出或接收個人訊息,就會在這裡出現。",
   "empty_column.hashtag": "這個標籤暫時未有內容。",
   "empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。",
   "empty_column.home.public_timeline": "公共時間軸",
@@ -135,7 +137,7 @@
   "keyboard_shortcuts.mention": "提及作者",
   "keyboard_shortcuts.reply": "回覆",
   "keyboard_shortcuts.search": "把標示移動到搜索",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "顯示或隱藏被標為敏感的文字",
   "keyboard_shortcuts.toot": "新的推文",
   "keyboard_shortcuts.unfocus": "把標示移離文字輸入和搜索",
   "keyboard_shortcuts.up": "在列表往上移動",
@@ -157,7 +159,7 @@
   "mute_modal.hide_notifications": "隱藏來自這用戶的通知嗎?",
   "navigation_bar.blocks": "被你封鎖的用戶",
   "navigation_bar.community_timeline": "本站時間軸",
-  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.direct": "個人訊息",
   "navigation_bar.domain_blocks": "隱藏的服務站",
   "navigation_bar.edit_profile": "修改個人資料",
   "navigation_bar.favourites": "最愛的內容",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "轉推你的文章:",
   "notifications.column_settings.show": "在通知欄顯示",
   "notifications.column_settings.sound": "播放音效",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "開始使用",
   "onboarding.next": "繼續",
   "onboarding.page_five.public_timelines": "「本站時間軸」顯示在 {domain} 各用戶的公開文章。「跨站時間軸」顯示在 {domain} 各人關注的所有用戶(包括其他服務站)的公開文章。這些都是「公共時間軸」,是認識新朋友的好地方。",
@@ -242,7 +245,7 @@
   "search_results.total": "{count, number} 項結果",
   "standalone.public_title": "站點一瞥…",
   "status.block": "封鎖 @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "取消轉推",
   "status.cannot_reblog": "這篇文章無法被轉推",
   "status.delete": "刪除",
   "status.direct": "私訊 @{name}",
@@ -258,7 +261,7 @@
   "status.pin": "置頂到資料頁",
   "status.pinned": "置頂文章",
   "status.reblog": "轉推",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "轉推到原讀者",
   "status.reblogged_by": "{name} 轉推",
   "status.reply": "回應",
   "status.replyAll": "回應所有人",
@@ -277,12 +280,14 @@
   "tabs_bar.local_timeline": "本站",
   "tabs_bar.notifications": "通知",
   "tabs_bar.search": "搜尋",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "如果你現在離開 Mastodon,你的草稿內容將會被丟棄。",
   "upload_area.title": "將檔案拖放至此上載",
   "upload_button.label": "上載媒體檔案",
   "upload_form.description": "為視覺障礙人士添加文字說明",
   "upload_form.focus": "裁切",
-  "upload_form.undo": "還原",
+  "upload_form.undo": "刪除",
   "upload_progress.label": "上載中……",
   "video.close": "關閉影片",
   "video.exit_fullscreen": "退出全熒幕",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 84ff25e03..91b44012f 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -1,4 +1,5 @@
 {
+  "account.badges.bot": "Bot",
   "account.block": "封鎖 @{name}",
   "account.block_domain": "隱藏來自 {domain} 的一切貼文",
   "account.blocked": "已被封鎖的",
@@ -60,6 +61,7 @@
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
   "compose_form.direct_message_warning": "此則推文只會被所有提到的使用者看見。",
+  "compose_form.direct_message_warning_learn_more": "Learn more",
   "compose_form.hashtag_warning": "此則推文將不會在任何主題標籤中看見,只有公開的推文可以用主題標籤來搜尋。",
   "compose_form.lock_disclaimer": "你的帳號沒有{locked}。任何人都可以關注你,看到發給關注者的貼文。",
   "compose_form.lock_disclaimer.lock": "上鎖",
@@ -185,6 +187,7 @@
   "notifications.column_settings.reblog": "轉推:",
   "notifications.column_settings.show": "顯示在欄位中",
   "notifications.column_settings.sound": "播放音效",
+  "notifications.group": "{count} notifications",
   "onboarding.done": "完成",
   "onboarding.next": "下一步",
   "onboarding.page_five.public_timelines": "本地時間軸顯示 {domain} 上所有人的公開貼文。聯盟時間軸顯示 {domain} 上所有人關注的公開貼文。這就是公開時間軸,發現新朋友的好地方。",
@@ -277,6 +280,8 @@
   "tabs_bar.local_timeline": "本地",
   "tabs_bar.notifications": "通知",
   "tabs_bar.search": "Search",
+  "timeline.media": "Media",
+  "timeline.posts": "Toots",
   "ui.beforeunload": "如果離開 Mastodon,你的草稿將會不見。",
   "upload_area.title": "拖放來上傳",
   "upload_button.label": "增加媒體",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 46d9d6c8e..62461d1a7 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -44,6 +44,7 @@ const initialState = ImmutableMap({
   privacy: null,
   text: '',
   focusDate: null,
+  caretPosition: null,
   preselectDate: null,
   in_reply_to: null,
   is_composing: false,
@@ -91,7 +92,6 @@ function appendMedia(state, media) {
     map.update('media_attachments', list => list.push(media));
     map.set('is_uploading', false);
     map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
-    map.set('focusDate', new Date());
     map.set('idempotencyKey', uuid());
 
     if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
@@ -119,6 +119,7 @@ const insertSuggestion = (state, position, token, completion) => {
     map.set('suggestion_token', null);
     map.update('suggestions', ImmutableList(), list => list.clear());
     map.set('focusDate', new Date());
+    map.set('caretPosition', position + completion.length + 1);
     map.set('idempotencyKey', uuid());
   });
 };
@@ -142,6 +143,7 @@ const insertEmoji = (state, position, emojiData, needsSpace) => {
   return state.merge({
     text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,
     focusDate: new Date(),
+    caretPosition: position + emoji.length + 1,
     idempotencyKey: uuid(),
   });
 };
@@ -216,6 +218,7 @@ export default function compose(state = initialState, action) {
       map.set('text', statusToTextMentions(state, action.status));
       map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
       map.set('focusDate', new Date());
+      map.set('caretPosition', null);
       map.set('preselectDate', new Date());
       map.set('idempotencyKey', uuid());
 
@@ -259,6 +262,7 @@ export default function compose(state = initialState, action) {
     return state.withMutations(map => {
       map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
       map.set('focusDate', new Date());
+      map.set('caretPosition', null);
       map.set('idempotencyKey', uuid());
     });
   case COMPOSE_DIRECT:
@@ -266,6 +270,7 @@ export default function compose(state = initialState, action) {
       map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
       map.set('privacy', 'direct');
       map.set('focusDate', new Date());
+      map.set('caretPosition', null);
       map.set('idempotencyKey', uuid());
     });
   case COMPOSE_SUGGESTIONS_CLEAR:
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
index ebd01e532..53e70b58e 100644
--- a/app/javascript/mastodon/reducers/contexts.js
+++ b/app/javascript/mastodon/reducers/contexts.js
@@ -3,38 +3,62 @@ import {
   ACCOUNT_MUTE_SUCCESS,
 } from '../actions/accounts';
 import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
-import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines';
+import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
-  ancestors: ImmutableMap(),
-  descendants: ImmutableMap(),
+  inReplyTos: ImmutableMap(),
+  replies: ImmutableMap(),
 });
 
-const normalizeContext = (state, id, ancestors, descendants) => {
-  const ancestorsIds   = ImmutableList(ancestors.map(ancestor => ancestor.id));
-  const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
+const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+      function addReply({ id, in_reply_to_id }) {
+        if (in_reply_to_id) {
+          const siblings = replies.get(in_reply_to_id, ImmutableList());
 
-  return state.withMutations(map => {
-    map.setIn(['ancestors', id], ancestorsIds);
-    map.setIn(['descendants', id], descendantsIds);
-  });
-};
+          if (!siblings.includes(id)) {
+            const index = siblings.findLastIndex(sibling => sibling.id < id);
+            replies.set(in_reply_to_id, siblings.insert(index + 1, id));
+          }
+
+          inReplyTos.set(id, in_reply_to_id);
+        }
+      }
+
+      if (ancestors[0]) {
+        addReply({ id, in_reply_to_id: ancestors[0].id });
+      }
+
+      if (descendants[0]) {
+        addReply({ id: descendants[0].id, in_reply_to_id: id });
+      }
+
+      [ancestors, descendants].forEach(statuses => statuses.forEach(addReply));
+    }));
+  }));
+});
 
 const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
-  state.update('ancestors', immutableAncestors => immutableAncestors.withMutations(ancestors => {
-    state.update('descendants', immutableDescendants => immutableDescendants.withMutations(descendants => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
       ids.forEach(id => {
-        descendants.get(id, ImmutableList()).forEach(descendantId => {
-          ancestors.update(descendantId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
-        });
+        const inReplyToIdOfId = inReplyTos.get(id);
+        const repliesOfId = replies.get(id);
+        const siblings = replies.get(inReplyToIdOfId);
 
-        ancestors.get(id, ImmutableList()).forEach(ancestorId => {
-          descendants.update(ancestorId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
-        });
+        if (siblings) {
+          replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
+        }
+
+
+        if (repliesOfId) {
+          repliesOfId.forEach(reply => inReplyTos.delete(reply));
+        }
 
-        descendants.delete(id);
-        ancestors.delete(id);
+        inReplyTos.delete(id);
+        replies.delete(id);
       });
     }));
   }));
@@ -48,23 +72,23 @@ const filterContexts = (state, relationship, statuses) => {
   return deleteFromContexts(state, ownedStatusIds);
 };
 
-const updateContext = (state, status, references) => {
-  return state.update('descendants', map => {
-    references.forEach(parentId => {
-      map = map.update(parentId, ImmutableList(), list => {
-        if (list.includes(status.id)) {
-          return list;
-        }
+const updateContext = (state, status) => {
+  if (status.in_reply_to_id) {
+    return state.withMutations(mutable => {
+      const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList());
 
-        return list.push(status.id);
-      });
+      mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
+
+      if (!replies.includes(status.id)) {
+        mutable.setIn(['replies', status.id], replies.push(status.id));
+      }
     });
+  }
 
-    return map;
-  });
+  return state;
 };
 
-export default function contexts(state = initialState, action) {
+export default function replies(state = initialState, action) {
   switch(action.type) {
   case ACCOUNT_BLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
@@ -73,8 +97,8 @@ export default function contexts(state = initialState, action) {
     return normalizeContext(state, action.id, action.ancestors, action.descendants);
   case TIMELINE_DELETE:
     return deleteFromContexts(state, [action.id]);
-  case TIMELINE_CONTEXT_UPDATE:
-    return updateContext(state, action.status, action.references);
+  case TIMELINE_UPDATE:
+    return updateContext(state, action.status);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3d9a6a132..019c1f466 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -26,6 +26,7 @@ import height_cache from './height_cache';
 import custom_emojis from './custom_emojis';
 import lists from './lists';
 import listEditor from './list_editor';
+import trends from './trends';
 
 const reducers = {
   dropdown_menu,
@@ -55,6 +56,7 @@ const reducers = {
   custom_emojis,
   lists,
   listEditor,
+  trends,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 56fd7226b..4758defb1 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -9,7 +9,7 @@ import {
   COMPOSE_REPLY,
   COMPOSE_DIRECT,
 } from '../actions/compose';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 const initialState = ImmutableMap({
   value: '',
@@ -39,7 +39,7 @@ export default function search(state = initialState, action) {
     return state.set('results', ImmutableMap({
       accounts: ImmutableList(action.results.accounts.map(item => item.id)),
       statuses: ImmutableList(action.results.statuses.map(item => item.id)),
-      hashtags: ImmutableList(action.results.hashtags),
+      hashtags: fromJS(action.results.hashtags),
     })).set('submitted', true);
   default:
     return state;
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 9ec52a7fa..de8865e43 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -1,5 +1,5 @@
 import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings';
-import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns';
 import { STORE_HYDRATE } from '../actions/store';
 import { EMOJI_USE } from '../actions/emojis';
 import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists';
@@ -89,6 +89,17 @@ const moveColumn = (state, uuid, direction) => {
     .set('saved', false);
 };
 
+const changeColumnParams = (state, uuid, params) => {
+  const columns = state.get('columns');
+  const index   = columns.findIndex(item => item.get('uuid') === uuid);
+
+  const newColumns = columns.update(index, column => column.update('params', () => fromJS(params)));
+
+  return state
+    .set('columns', newColumns)
+    .set('saved', false);
+};
+
 const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
 
 const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId));
@@ -111,6 +122,8 @@ export default function settings(state = initialState, action) {
       .set('saved', false);
   case COLUMN_MOVE:
     return moveColumn(state, action.uuid, action.direction);
+  case COLUMN_PARAMS_CHANGE:
+    return changeColumnParams(state, action.uuid, action.params);
   case EMOJI_USE:
     return updateFrequentEmojis(state, action.emoji);
   case SETTING_SAVE:
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index dd675d78f..916a091eb 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -33,6 +33,11 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) =>
     if (!statuses.isEmpty()) {
       mMap.update('items', ImmutableList(), oldIds => {
         const newIds = statuses.map(status => status.get('id'));
+
+        if (timeline.indexOf(':pinned') !== -1) {
+          return newIds;
+        }
+
         const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
         const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
 
@@ -134,7 +139,7 @@ export default function timelines(state = initialState, action) {
       initialTimeline,
       map => map.update(
         'items',
-        items => items.first() ? items : items.unshift(null)
+        items => items.first() ? items.unshift(null) : items
       )
     );
   default:
diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js
new file mode 100644
index 000000000..95cf8f284
--- /dev/null
+++ b/app/javascript/mastodon/reducers/trends.js
@@ -0,0 +1,13 @@
+import { TRENDS_FETCH_SUCCESS } from '../actions/trends';
+import { fromJS } from 'immutable';
+
+const initialState = null;
+
+export default function trendsReducer(state = initialState, action) {
+  switch(action.type) {
+  case TRENDS_FETCH_SUCCESS:
+    return fromJS(action.trends);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js
index ba54ae996..c1854c1cd 100644
--- a/app/javascript/mastodon/service_worker/entry.js
+++ b/app/javascript/mastodon/service_worker/entry.js
@@ -1,4 +1,4 @@
-import { freeStorage } from '../storage/modifier';
+import { freeStorage, storageFreeable } from '../storage/modifier';
 import './web_push_notifications';
 
 function openSystemCache() {
@@ -10,9 +10,12 @@ function openWebCache() {
 }
 
 function fetchRoot() {
-  return fetch('/', { credentials: 'include' });
+  return fetch('/', { credentials: 'include', redirect: 'manual' });
 }
 
+const firefox = navigator.userAgent.match(/Firefox\/(\d+)/);
+const invalidOnlyIfCached = firefox && firefox[1] < 60;
+
 // Cause a new version of a registered Service Worker to replace an existing one
 // that is already installed, and replace the currently active worker on open pages.
 self.addEventListener('install', function(event) {
@@ -28,48 +31,49 @@ self.addEventListener('fetch', function(event) {
     const asyncResponse = fetchRoot();
     const asyncCache = openWebCache();
 
-    event.respondWith(asyncResponse.then(async response => {
-      if (response.ok) {
-        const cache = await asyncCache;
-        await cache.put('/', response);
-        return response.clone();
-      }
-
-      throw null;
-    }).catch(() => asyncCache.then(cache => cache.match('/'))));
+    event.respondWith(asyncResponse.then(
+      response => asyncCache.then(cache => cache.put('/', response.clone()))
+                            .then(() => response),
+      () => asyncCache.then(cache => cache.match('/'))));
   } else if (url.pathname === '/auth/sign_out') {
     const asyncResponse = fetch(event.request);
     const asyncCache = openWebCache();
 
-    event.respondWith(asyncResponse.then(async response => {
+    event.respondWith(asyncResponse.then(response => {
       if (response.ok || response.type === 'opaqueredirect') {
-        await Promise.all([
+        return Promise.all([
           asyncCache.then(cache => cache.delete('/')),
           indexedDB.deleteDatabase('mastodon'),
-        ]);
+        ]).then(() => response);
       }
 
       return response;
     }));
-  } else if (process.env.CDN_HOST ? url.host === process.env.CDN_HOST : url.pathname.startsWith('/system/')) {
-    event.respondWith(openSystemCache().then(async cache => {
-      const cached = await cache.match(event.request.url);
+  } else if (storageFreeable && (ATTACHMENT_HOST ? url.host === ATTACHMENT_HOST : url.pathname.startsWith('/system/'))) {
+    event.respondWith(openSystemCache().then(cache => {
+      return cache.match(event.request.url).then(cached => {
+        if (cached === undefined) {
+          const asyncResponse = invalidOnlyIfCached && event.request.cache === 'only-if-cached' ?
+            fetch(event.request, { cache: 'no-cache' }) : fetch(event.request);
 
-      if (cached === undefined) {
-        const fetched = await fetch(event.request);
+          return asyncResponse.then(response => {
+            if (response.ok) {
+              const put = cache.put(event.request.url, response.clone());
 
-        if (fetched.ok) {
-          try {
-            await cache.put(event.request.url, fetched.clone());
-          } finally {
-            freeStorage();
-          }
-        }
+              put.catch(() => freeStorage());
 
-        return fetched;
-      }
+              return put.then(() => {
+                freeStorage();
+                return response;
+              });
+            }
+
+            return response;
+          });
+        }
 
-      return cached;
+        return cached;
+      });
     }));
   }
 });
diff --git a/app/javascript/mastodon/service_worker/web_push_locales.js b/app/javascript/mastodon/service_worker/web_push_locales.js
new file mode 100644
index 000000000..ce96ae297
--- /dev/null
+++ b/app/javascript/mastodon/service_worker/web_push_locales.js
@@ -0,0 +1,30 @@
+/* @preval */
+
+const fs   = require('fs');
+const path = require('path');
+
+const filtered  = {};
+const filenames = fs.readdirSync(path.resolve(__dirname, '../locales'));
+
+filenames.forEach(filename => {
+  if (!filename.match(/\.json$/) || filename.match(/defaultMessages|whitelist/)) return;
+
+  const content = fs.readFileSync(path.resolve(__dirname, `../locales/${filename}`), 'utf-8');
+  const full    = JSON.parse(content);
+  const locale  = filename.split('.')[0];
+
+  filtered[locale] = {
+    'notification.favourite': full['notification.favourite'] || '',
+    'notification.follow': full['notification.follow'] || '',
+    'notification.mention': full['notification.mention'] || '',
+    'notification.reblog': full['notification.reblog'] || '',
+
+    'status.show_more': full['status.show_more'] || '',
+    'status.reblog': full['status.reblog'] || '',
+    'status.favourite': full['status.favourite'] || '',
+
+    'notifications.group': full['notifications.group'] || '',
+  };
+});
+
+module.exports = JSON.parse(JSON.stringify(filtered));
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index f63cff335..3318bbadc 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -1,36 +1,33 @@
+import IntlMessageFormat from 'intl-messageformat';
+import locales from './web_push_locales';
+import { unescape } from 'lodash';
+
 const MAX_NOTIFICATIONS = 5;
 const GROUP_TAG = 'tag';
 
-// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
-const formatGroupTitle = (message, count) => message.replace('%{count}', count);
-
 const notify = options =>
   self.registration.getNotifications().then(notifications => {
-    if (notifications.length === MAX_NOTIFICATIONS) {
-      // Reached the maximum number of notifications, proceed with grouping
+    if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping
       const group = {
-        title: formatGroupTitle(options.data.message, notifications.length + 1),
-        body: notifications
-          .sort((n1, n2) => n1.timestamp < n2.timestamp)
-          .map(notification => notification.title).join('\n'),
+        title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }),
+        body: notifications.sort((n1, n2) => n1.timestamp < n2.timestamp).map(notification => notification.title).join('\n'),
         badge: '/badge.png',
         icon: '/android-chrome-192x192.png',
         tag: GROUP_TAG,
         data: {
           url: (new URL('/web/notifications', self.location)).href,
           count: notifications.length + 1,
-          message: options.data.message,
+          preferred_locale: options.data.preferred_locale,
         },
       };
 
       notifications.forEach(notification => notification.close());
 
       return self.registration.showNotification(group.title, group);
-    } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
-      // Already grouped, proceed with appending the notification to the group
+    } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) { // Already grouped, proceed with appending the notification to the group
       const group = cloneNotification(notifications[0]);
 
-      group.title = formatGroupTitle(group.data.message, group.data.count + 1);
+      group.title = formatMessage('notifications.group', options.data.preferred_locale, { count: group.data.count + 1 });
       group.body  = `${options.title}\n${group.body}`;
       group.data  = { ...group.data, count: group.data.count + 1 };
 
@@ -40,57 +37,102 @@ const notify = options =>
     return self.registration.showNotification(options.title, options);
   });
 
-const handlePush = (event) => {
-  const options = event.data.json();
-
-  options.body      = options.data.nsfw || options.data.content;
-  options.dir       = options.data.dir;
-  options.image     = options.image || undefined; // Null results in a network request (404)
-  options.timestamp = options.timestamp && new Date(options.timestamp);
-
-  const expandAction = options.data.actions.find(action => action.todo === 'expand');
-
-  if (expandAction) {
-    options.actions          = [expandAction];
-    options.hiddenActions    = options.data.actions.filter(action => action !== expandAction);
-    options.data.hiddenImage = options.image;
-    options.image            = undefined;
-  } else {
-    options.actions = options.data.actions;
-  }
+const fetchFromApi = (path, method, accessToken) => {
+  const url = (new URL(path, self.location)).href;
 
-  event.waitUntil(notify(options));
+  return fetch(url, {
+    headers: {
+      'Authorization': `Bearer ${accessToken}`,
+      'Content-Type': 'application/json',
+    },
+
+    method: method,
+    credentials: 'include',
+  }).then(res => {
+    if (res.ok) {
+      return res;
+    } else {
+      throw new Error(res.status);
+    }
+  }).then(res => res.json());
 };
 
-const cloneNotification = (notification) => {
-  const clone = {  };
+const cloneNotification = notification => {
+  const clone = {};
+  let k;
 
-  for(var k in notification) {
+  // Object.assign() does not work with notifications
+  for(k in notification) {
     clone[k] = notification[k];
   }
 
   return clone;
 };
 
-const expandNotification = (notification) => {
-  const nextNotification = cloneNotification(notification);
+const formatMessage = (messageId, locale, values = {}) =>
+  (new IntlMessageFormat(locales[locale][messageId], locale)).format(values);
+
+const htmlToPlainText = html =>
+  unescape(html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, ''));
 
-  nextNotification.body    = notification.data.content;
-  nextNotification.image   = notification.data.hiddenImage;
-  nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
+const handlePush = (event) => {
+  const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json();
+
+  // Placeholder until more information can be loaded
+  event.waitUntil(
+    notify({
+      title,
+      body,
+      icon,
+      tag: notification_id,
+      timestamp: new Date(),
+      badge: '/badge.png',
+      data: { access_token, preferred_locale, url: '/web/notifications' },
+    }).then(() => fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token)).then(notification => {
+      const options = {};
+
+      options.title     = formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+      options.body      = notification.status && htmlToPlainText(notification.status.content);
+      options.icon      = notification.account.avatar_static;
+      options.timestamp = notification.created_at && new Date(notification.created_at);
+      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}` };
+
+      if (notification.status && notification.status.sensitive) {
+        options.data.hiddenBody  = htmlToPlainText(notification.status.content);
+        options.data.hiddenImage = notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url;
+
+        options.body    = notification.status.spoiler_text;
+        options.image   = undefined;
+        options.actions = [actionExpand(preferred_locale)];
+      } else if (notification.type === 'mention') {
+        options.actions = [actionReblog(preferred_locale), actionFavourite(preferred_locale)];
+      }
 
-  return self.registration.showNotification(nextNotification.title, nextNotification);
+      return notify(options);
+    })
+  );
 };
 
-const makeRequest = (notification, action) =>
-  fetch(action.action, {
-    headers: {
-      'Authorization': `Bearer ${notification.data.access_token}`,
-      'Content-Type': 'application/json',
-    },
-    method: action.method,
-    credentials: 'include',
-  });
+const actionExpand = preferred_locale => ({
+  action: 'expand',
+  icon: '/web-push-icon_expand.png',
+  title: formatMessage('status.show_more', preferred_locale),
+});
+
+const actionReblog = preferred_locale => ({
+  action: 'reblog',
+  icon: '/web-push-icon_reblog.png',
+  title: formatMessage('status.reblog', preferred_locale),
+});
+
+const actionFavourite = preferred_locale => ({
+  action: 'favourite',
+  icon: '/web-push-icon_favourite.png',
+  title: formatMessage('status.favourite', preferred_locale),
+});
 
 const findBestClient = clients => {
   const focusedClient = clients.find(client => client.focused);
@@ -99,6 +141,24 @@ const findBestClient = clients => {
   return focusedClient || visibleClient || clients[0];
 };
 
+const expandNotification = notification => {
+  const newNotification = cloneNotification(notification);
+
+  newNotification.body    = newNotification.data.hiddenBody;
+  newNotification.image   = newNotification.data.hiddenImage;
+  newNotification.actions = [actionReblog(notification.data.preferred_locale), actionFavourite(notification.data.preferred_locale)];
+
+  return self.registration.showNotification(newNotification.title, newNotification);
+};
+
+const removeActionFromNotification = (notification, action) => {
+  const newNotification = cloneNotification(notification);
+
+  newNotification.actions = newNotification.actions.filter(item => item.action !== action);
+
+  return self.registration.showNotification(newNotification.title, newNotification);
+};
+
 const openUrl = url =>
   self.clients.matchAll({ type: 'window' }).then(clientList => {
     if (clientList.length !== 0) {
@@ -124,27 +184,19 @@ const openUrl = url =>
     return self.clients.openWindow(url);
   });
 
-const removeActionFromNotification = (notification, action) => {
-  const actions          = notification.actions.filter(act => act.action !== action.action);
-  const nextNotification = cloneNotification(notification);
-
-  nextNotification.actions = actions;
-
-  return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
 const handleNotificationClick = (event) => {
   const reactToNotificationClick = new Promise((resolve, reject) => {
     if (event.action) {
-      const action = event.notification.data.actions.find(({ action }) => action === event.action);
-
-      if (action.todo === 'expand') {
+      if (event.action === 'expand') {
         resolve(expandNotification(event.notification));
-      } else if (action.todo === 'request') {
-        resolve(makeRequest(event.notification, action)
-          .then(() => removeActionFromNotification(event.notification, action)));
+      } else if (event.action === 'reblog') {
+        const { data } = event.notification;
+        resolve(fetchFromApi(`/api/v1/statuses/${data.id}/reblog`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'reblog')));
+      } else if (event.action === 'favourite') {
+        const { data } = event.notification;
+        resolve(fetchFromApi(`/api/v1/statuses/${data.id}/favourite`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'favourite')));
       } else {
-        reject(`Unknown action: ${action.todo}`);
+        reject(`Unknown action: ${event.action}`);
       }
     } else {
       event.notification.close();
diff --git a/app/javascript/mastodon/storage/modifier.js b/app/javascript/mastodon/storage/modifier.js
index c2ed6f807..9fadabef4 100644
--- a/app/javascript/mastodon/storage/modifier.js
+++ b/app/javascript/mastodon/storage/modifier.js
@@ -4,6 +4,11 @@ const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static'];
 const storageMargin = 8388608;
 const storeLimit = 1024;
 
+// navigator.storage is not present on:
+// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.100 Safari/537.36 Edge/16.16299
+// estimate method is not present on Chrome 57.0.2987.98 on Linux.
+export const storageFreeable = 'storage' in navigator && 'estimate' in navigator.storage;
+
 function openCache() {
   // ServiceWorker and Cache API is not available on iOS 11
   // https://webkit.org/status/#specification-service-workers
@@ -182,7 +187,7 @@ export function putStatuses(records) {
 }
 
 export function freeStorage() {
-  return navigator.storage.estimate().then(({ quota, usage }) => {
+  return storageFreeable && navigator.storage.estimate().then(({ quota, usage }) => {
     if (usage + storageMargin < quota) {
       return null;
     }
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 6c67ba275..9928d0dd7 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -1,21 +1,24 @@
 import WebSocketClient from 'websocket.js';
 
+const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
+
 export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) {
   return (dispatch, getState) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
     const accessToken = getState().getIn(['meta', 'access_token']);
     const { onDisconnect, onReceive } = callbacks(dispatch, getState);
+
     let polling = null;
 
     const setupPolling = () => {
-      polling = setInterval(() => {
-        pollingRefresh(dispatch);
-      }, 20000);
+      pollingRefresh(dispatch, () => {
+        polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000));
+      });
     };
 
     const clearPolling = () => {
       if (polling) {
-        clearInterval(polling);
+        clearTimeout(polling);
         polling = null;
       }
     };
@@ -29,8 +32,9 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
 
       disconnected () {
         if (pollingRefresh) {
-          setupPolling();
+          polling = setTimeout(() => setupPolling(), randomIntUpTo(40000));
         }
+
         onDisconnect();
       },
 
@@ -51,6 +55,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
       if (subscription) {
         subscription.close();
       }
+
       clearPolling();
     };
 
diff --git a/app/javascript/mastodon/utils/html.js b/app/javascript/mastodon/utils/html.js
new file mode 100644
index 000000000..0b646ce58
--- /dev/null
+++ b/app/javascript/mastodon/utils/html.js
@@ -0,0 +1,6 @@
+export const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  html = html.replace(/<br \/>|<br>|\n/g, ' ');
+  wrapper.innerHTML = html;
+  return wrapper.textContent;
+};
diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js
index 6442eda38..279a858ca 100644
--- a/app/javascript/mastodon/utils/resize_image.js
+++ b/app/javascript/mastodon/utils/resize_image.js
@@ -1,3 +1,5 @@
+import EXIF from 'exif-js';
+
 const MAX_IMAGE_DIMENSION = 1280;
 
 const getImageUrl = inputFile => new Promise((resolve, reject) => {
@@ -28,6 +30,73 @@ const loadImage = inputFile => new Promise((resolve, reject) => {
   }).catch(reject);
 });
 
+const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
+  if (type !== 'image/jpeg') {
+    resolve(1);
+    return;
+  }
+
+  EXIF.getData(img, () => {
+    const orientation = EXIF.getTag(img, 'Orientation');
+    resolve(orientation);
+  });
+});
+
+const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => {
+  const canvas  = document.createElement('canvas');
+
+  if (4 < orientation && orientation < 9) {
+    canvas.width  = height;
+    canvas.height = width;
+  } else {
+    canvas.width  = width;
+    canvas.height = height;
+  }
+
+  const context = canvas.getContext('2d');
+
+  switch (orientation) {
+  case 2: context.transform(-1, 0, 0, 1, width, 0); break;
+  case 3: context.transform(-1, 0, 0, -1, width, height); break;
+  case 4: context.transform(1, 0, 0, -1, 0, height); break;
+  case 5: context.transform(0, 1, 1, 0, 0, 0); break;
+  case 6: context.transform(0, 1, -1, 0, height, 0); break;
+  case 7: context.transform(0, -1, -1, 0, height, width); break;
+  case 8: context.transform(0, -1, 1, 0, 0, width); break;
+  }
+
+  context.drawImage(img, 0, 0, width, height);
+
+  canvas.toBlob(resolve, type);
+});
+
+const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => {
+  const { width, height } = img;
+
+  let newWidth, newHeight;
+
+  if (width > height) {
+    newHeight = height * MAX_IMAGE_DIMENSION / width;
+    newWidth  = MAX_IMAGE_DIMENSION;
+  } else if (height > width) {
+    newWidth  = width * MAX_IMAGE_DIMENSION / height;
+    newHeight = MAX_IMAGE_DIMENSION;
+  } else {
+    newWidth  = MAX_IMAGE_DIMENSION;
+    newHeight = MAX_IMAGE_DIMENSION;
+  }
+
+  getOrientation(img, type)
+    .then(orientation => processImage(img, {
+      width: newWidth,
+      height: newHeight,
+      orientation,
+      type,
+    }))
+    .then(resolve)
+    .catch(reject);
+});
+
 export default inputFile => new Promise((resolve, reject) => {
   if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
     resolve(inputFile);
@@ -35,32 +104,13 @@ export default inputFile => new Promise((resolve, reject) => {
   }
 
   loadImage(inputFile).then(img => {
-    const canvas = document.createElement('canvas');
-    const { width, height } = img;
-
-    let newWidth, newHeight;
-
-    if (width < MAX_IMAGE_DIMENSION && height < MAX_IMAGE_DIMENSION) {
+    if (img.width < MAX_IMAGE_DIMENSION && img.height < MAX_IMAGE_DIMENSION) {
       resolve(inputFile);
       return;
     }
 
-    if (width > height) {
-      newHeight = height * MAX_IMAGE_DIMENSION / width;
-      newWidth  = MAX_IMAGE_DIMENSION;
-    } else if (height > width) {
-      newWidth  = width * MAX_IMAGE_DIMENSION / height;
-      newHeight = MAX_IMAGE_DIMENSION;
-    } else {
-      newWidth  = MAX_IMAGE_DIMENSION;
-      newHeight = MAX_IMAGE_DIMENSION;
-    }
-
-    canvas.width  = newWidth;
-    canvas.height = newHeight;
-
-    canvas.getContext('2d').drawImage(img, 0, 0, newWidth, newHeight);
-
-    canvas.toBlob(resolve, inputFile.type);
+    resizeImage(img, inputFile.type)
+      .then(resolve)
+      .catch(() => resolve(inputFile));
   }).catch(reject);
 });
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3377c2329..1e6ee62af 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -6,8 +6,6 @@ function main() {
   const emojify = require('../mastodon/features/emoji/emoji').default;
   const { getLocale } = require('../mastodon/locales');
   const { localeData } = getLocale();
-  const VideoContainer = require('../mastodon/containers/video_container').default;
-  const CardContainer = require('../mastodon/containers/card_container').default;
   const React = require('react');
   const ReactDOM = require('react-dom');
 
@@ -52,24 +50,16 @@ function main() {
       });
     });
 
-    [].forEach.call(document.querySelectorAll('[data-component="Video"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<VideoContainer locale={locale} {...props} />, content);
-    });
-
-    [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
-    });
-
-    const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]');
-
-    if (mediaGalleries.length > 0) {
-      const MediaGalleriesContainer = require('../mastodon/containers/media_galleries_container').default;
-      const content = document.createElement('div');
+    const reactComponents = document.querySelectorAll('[data-component]');
+    if (reactComponents.length > 0) {
+      import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
+        .then(({ default: MediaContainer }) => {
+          const content = document.createElement('div');
 
-      ReactDOM.render(<MediaGalleriesContainer locale={locale} galleries={mediaGalleries} />, content);
-      document.body.appendChild(content);
+          ReactDOM.render(<MediaContainer locale={locale} components={reactComponents} />, content);
+          document.body.appendChild(content);
+        })
+        .catch(error => console.error(error));
     }
   });
 }
diff --git a/app/javascript/skins/glitch/mastodon-light/common.scss b/app/javascript/skins/glitch/mastodon-light/common.scss
new file mode 100644
index 000000000..c37f407b3
--- /dev/null
+++ b/app/javascript/skins/glitch/mastodon-light/common.scss
@@ -0,0 +1 @@
+@import 'flavours/glitch/styles/mastodon-light';
diff --git a/app/javascript/skins/glitch/mastodon-light/names.yml b/app/javascript/skins/glitch/mastodon-light/names.yml
new file mode 100644
index 000000000..f15424f2b
--- /dev/null
+++ b/app/javascript/skins/glitch/mastodon-light/names.yml
@@ -0,0 +1,5 @@
+en:
+  skins:
+    glitch:
+      mastodon-light: Mastodon (light)
+
diff --git a/app/javascript/styles/mastodon-light.scss b/app/javascript/styles/mastodon-light.scss
index 6a22a7822..756a12d86 100644
--- a/app/javascript/styles/mastodon-light.scss
+++ b/app/javascript/styles/mastodon-light.scss
@@ -1,228 +1,3 @@
-// Set variables
-$ui-base-color: #d9e1e8;
-$ui-base-lighter-color: darken($ui-base-color, 57%);
-$ui-highlight-color: #2b90d9;
-$ui-primary-color: darken($ui-highlight-color, 28%);
-$ui-secondary-color: #282c37;
-
-$primary-text-color: black;
-$base-overlay-background: $ui-base-color;
-
-$login-button-color: white;
-$account-background-color: white;
-
-// Import defaults
+@import 'mastodon-light/variables';
 @import 'application';
-
-// Change the color of the log in button
-.button {
-  &.button-alternative-2 {
-    color: $login-button-color;
-  }
-}
-
-// Change columns' default background colors
-.column {
-  > .scrollable {
-    background: lighten($ui-base-color, 13%);
-  }
-}
-
-.drawer__inner {
-  background: $ui-base-color;
-}
-
-.drawer__inner__mastodon {
-  background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color(lighten($ui-base-color, 13%))}"/></svg>') no-repeat bottom / 100% auto;
-}
-
-// Change the default appearance of the content warning button
-.status__content,
-.reply-indicator__content {
-
-  .status__content__spoiler-link {
-
-    background: darken($ui-base-color, 30%);
-
-    &:hover {
-      background: darken($ui-base-color, 35%);
-      text-decoration: none;
-    }
-
-  }
-
-}
-
-// Change the default appearance of the action buttons
-.icon-button {
-
-  &:hover,
-  &:active,
-  &:focus {
-    color: darken($ui-base-color, 40%);
-    transition: color 200ms ease-out;
-  }
-
-  &.disabled {
-    color: darken($ui-base-color, 30%);
-  }
-
-}
-
-.status {
-  &.status-direct {
-    .icon-button.disabled {
-      color: darken($ui-base-color, 30%);
-    }
-  }
-}
-
-button.icon-button i.fa-retweet {
-  &:hover {
-    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(darken($ui-base-color, 30%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>");
-  }
-}
-
-button.icon-button.disabled i.fa-retweet {
-  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(darken($ui-base-color, 30%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>");
-}
-
-// Change the colors used in the dropdown menu
-.dropdown-menu {
-  background: $ui-base-color;
-}
-
-.dropdown-menu__arrow {
-
-  &.left {
-    border-left-color: $ui-base-color;
-  }
-
-  &.top {
-    border-top-color: $ui-base-color;
-  }
-
-  &.bottom {
-    border-bottom-color: $ui-base-color;
-  }
-
-  &.right {
-    border-right-color: $ui-base-color;
-  }
-
-}
-
-.dropdown-menu__item {
-  a {
-    background: $ui-base-color;
-    color: $ui-secondary-color;
-  }
-}
-
-// Change the default color of several parts of the compose form
-.compose-form {
-
-  .compose-form__warning {
-    color: lighten($ui-secondary-color, 65%);
-  }
-
-  strong {
-    color: lighten($ui-secondary-color, 65%);
-  }
-
-  .autosuggest-textarea__textarea,
-  .spoiler-input__input {
-
-    color: darken($ui-base-color, 80%);
-
-    &::placeholder {
-      color: darken($ui-base-color, 70%);
-    }
-
-  }
-
-  .compose-form__buttons-wrapper {
-    background: darken($ui-base-color, 10%);
-  }
-
-  .privacy-dropdown__option {
-    color: $ui-primary-color;
-  }
-
-  .privacy-dropdown__option__content {
-
-    strong {
-      color: $ui-primary-color;
-    }
-
-  }
-
-}
-
-// Change the default color used for the text in an empty column or on the error column
-.empty-column-indicator,
-.error-column {
-  color: darken($ui-base-color, 60%);
-}
-
-// Change the default colors used on some parts of the profile pages
-.activity-stream-tabs {
-
-  background: $account-background-color;
-
-  a {
-    &.active {
-      color: $ui-primary-color;
-      }
-  }
-
-}
-
-.activity-stream {
-
-  .entry {
-    background: $account-background-color;
-  }
-
-  .status.light {
-
-    .status__content {
-      color: $primary-text-color;
-    }
-
-    .display-name {
-      strong {
-        color: $primary-text-color;
-      }
-    }
-
-  }
-
-}
-
-.accounts-grid {
-  .account-grid-card {
-
-    .controls {
-      .icon-button {
-        color: $ui-secondary-color;
-      }
-    }
-
-    .name {
-      a {
-        color: $primary-text-color;
-      }
-    }
-
-    .username {
-      color: $ui-secondary-color;
-    }
-
-    .account__header__content {
-      color: $primary-text-color;
-    }
-
-  }
-}
-
+@import 'mastodon-light/diff';
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
new file mode 100644
index 000000000..fe304317d
--- /dev/null
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -0,0 +1,240 @@
+// Notes!
+// Sass color functions, "darken" and "lighten" are automatically replaced.
+
+// Change the colors of button texts
+.button {
+  color: $white;
+
+  &.button-alternative-2 {
+    color: $white;
+  }
+}
+
+// Change default background colors of columns
+.column {
+  > .scrollable {
+    background: $white;
+  }
+}
+
+.drawer__inner {
+  background: $ui-base-color;
+}
+
+.drawer__inner__mastodon {
+  background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($white)}"/></svg>') no-repeat bottom / 100% auto;
+}
+
+.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button {
+  color: $ui-base-color;
+
+  &:active,
+  &:focus,
+  &:hover {
+    color: darken($ui-base-color, 7%);
+  }
+}
+
+.compose-form .compose-form__modifiers .compose-form__upload-description input {
+  color: $ui-base-color;
+
+  &::placeholder {
+    color: $ui-base-color;
+  }
+}
+
+.compose-form .compose-form__buttons-wrapper {
+  background: darken($ui-base-color, 6%);
+}
+
+.focusable:focus {
+  background: $ui-base-color;
+}
+
+.status.status-direct {
+  background: lighten($ui-base-color, 4%);
+}
+
+.focusable:focus .status.status-direct {
+  background: lighten($ui-base-color, 8%);
+}
+
+.detailed-status,
+.detailed-status__action-bar {
+  background: darken($ui-base-color, 6%);
+}
+
+// Change the background color of status__content__spoiler-link
+.reply-indicator__content .status__content__spoiler-link,
+.status__content .status__content__spoiler-link {
+  background: $ui-base-lighter-color;
+
+  &:hover {
+    background: lighten($ui-base-lighter-color, 6%);
+  }
+}
+
+// Change the colors used in the dropdown menu
+.dropdown-menu {
+  background: $ui-base-color;
+}
+
+.dropdown-menu__arrow {
+  &.left {
+    border-left-color: $ui-base-color;
+  }
+
+  &.top {
+    border-top-color: $ui-base-color;
+  }
+
+  &.bottom {
+    border-bottom-color: $ui-base-color;
+  }
+
+  &.right {
+    border-right-color: $ui-base-color;
+  }
+}
+
+.dropdown-menu__item {
+  a {
+    background: $ui-base-color;
+    color: $ui-secondary-color;
+  }
+}
+
+// Change the text colors on inverted background
+.privacy-dropdown__option.active .privacy-dropdown__option__content,
+.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
+.dropdown-menu__item a:active,
+.dropdown-menu__item a:focus,
+.dropdown-menu__item a:hover,
+.actions-modal ul li:not(:empty) a.active,
+.actions-modal ul li:not(:empty) a.active button,
+.actions-modal ul li:not(:empty) a:active,
+.actions-modal ul li:not(:empty) a:active button,
+.actions-modal ul li:not(:empty) a:focus,
+.actions-modal ul li:not(:empty) a:focus button,
+.actions-modal ul li:not(:empty) a:hover,
+.actions-modal ul li:not(:empty) a:hover button,
+.admin-wrapper .sidebar ul ul a.selected,
+.simple_form .block-button,
+.simple_form .button,
+.simple_form button {
+  color: $white;
+}
+
+// Change the background colors of modals
+.actions-modal,
+.boost-modal,
+.confirmation-modal,
+.mute-modal,
+.report-modal,
+.embed-modal,
+.error-modal,
+.onboarding-modal {
+  background: $ui-base-color;
+}
+
+.boost-modal__action-bar,
+.confirmation-modal__action-bar,
+.mute-modal__action-bar,
+.onboarding-modal__paginator,
+.error-modal__footer {
+  background: darken($ui-base-color, 6%);
+
+  .onboarding-modal__nav,
+  .error-modal__nav {
+    &:hover,
+    &:focus,
+    &:active {
+      background-color: darken($ui-base-color, 12%);
+    }
+  }
+}
+
+.display-case__case {
+  background: $white;
+}
+
+.embed-modal .embed-modal__container .embed-modal__html {
+  background: $white;
+
+  &:focus {
+    background: darken($ui-base-color, 6%);
+  }
+}
+
+.react-toggle-track {
+  background: $ui-secondary-color;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: darken($ui-secondary-color, 10%);
+}
+
+.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: lighten($ui-highlight-color, 10%);
+}
+
+// Change the default color used for the text in an empty column or on the error column
+.empty-column-indicator,
+.error-column {
+  color: $primary-text-color;
+}
+
+// Change the default colors used on some parts of the profile pages
+.activity-stream-tabs {
+  background: $account-background-color;
+
+  a {
+    &.active {
+      color: $ui-primary-color;
+    }
+  }
+}
+
+.activity-stream {
+  .entry {
+    background: $account-background-color;
+  }
+
+  .status.light {
+    .status__content {
+      color: $primary-text-color;
+    }
+
+    .display-name {
+      strong {
+        color: $primary-text-color;
+      }
+    }
+  }
+}
+
+.accounts-grid {
+  .account-grid-card {
+    .controls {
+      .icon-button {
+        color: $ui-secondary-color;
+      }
+    }
+
+    .name {
+      a {
+        color: $primary-text-color;
+      }
+    }
+
+    .username {
+      color: $ui-secondary-color;
+    }
+
+    .account__header__content {
+      color: $primary-text-color;
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss
new file mode 100644
index 000000000..9f6d470b1
--- /dev/null
+++ b/app/javascript/styles/mastodon-light/variables.scss
@@ -0,0 +1,41 @@
+// Dependent colors
+$black: #000000;
+$white: #ffffff;
+
+$classic-base-color: #282c37;
+$classic-primary-color: #9baec8;
+$classic-secondary-color: #d9e1e8;
+$classic-highlight-color: #2b90d9;
+
+// Differences
+$success-green: #3c754d;
+
+$base-overlay-background: $white !default;
+$valid-value-color: $success-green !default;
+
+$ui-base-color: $classic-secondary-color !default;
+$ui-base-lighter-color: #b0c0cf;
+$ui-primary-color: #9bcbed;
+$ui-secondary-color: $classic-base-color !default;
+$ui-highlight-color: #2b5fd9;
+
+$primary-text-color: $black !default;
+$darker-text-color: $classic-base-color !default;
+$dark-text-color: #444b5d;
+$action-button-color: #606984;
+
+$inverted-text-color: $black !default;
+$lighter-text-color: $classic-base-color !default;
+$light-text-color: #444b5d;
+
+//Newly added colors
+$account-background-color: $white !default;
+
+//Invert darkened and lightened colors
+@function darken($color, $amount) {
+  @return hsl(hue($color), saturation($color), lightness($color) + $amount);
+}
+
+@function lighten($color, $amount) {
+  @return hsl(hue($color), saturation($color), lightness($color) - $amount);
+}
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index c9c0e3081..77728995d 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -396,7 +396,7 @@ $small-breakpoint: 960px;
         display: flex;
         justify-content: center;
         align-items: center;
-        color: $ui-primary-color;
+        color: $darker-text-color;
         text-decoration: none;
         padding: 12px 16px;
         line-height: 32px;
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index c2d0de4b9..3ccce383b 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -1,5 +1,5 @@
 .card {
-  background-color: lighten($ui-base-color, 4%);
+  background-color: $base-shadow-color;
   background-size: cover;
   background-position: center;
   border-radius: 4px 4px 0 0;
@@ -79,6 +79,10 @@
       font-weight: 400;
       overflow: hidden;
       text-overflow: ellipsis;
+
+      .fa {
+        margin-left: 3px;
+      }
     }
   }
 
@@ -322,6 +326,15 @@
   z-index: 2;
   position: relative;
 
+  &.empty img {
+    position: absolute;
+    opacity: 0.2;
+    height: 200px;
+    left: 0;
+    bottom: 0;
+    pointer-events: none;
+  }
+
   @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
@@ -438,8 +451,8 @@
   font-size: 14px;
   font-weight: 500;
   text-align: center;
-  padding: 60px 0;
-  padding-top: 55px;
+  padding: 130px 0;
+  padding-top: 125px;
   margin: 0 auto;
   cursor: default;
 }
@@ -565,36 +578,41 @@
 }
 
 .account__header__fields {
-  border-collapse: collapse;
   padding: 0;
   margin: 15px -15px -15px;
   border: 0 none;
   border-top: 1px solid lighten($ui-base-color, 4%);
   border-bottom: 1px solid lighten($ui-base-color, 4%);
+  font-size: 14px;
+  line-height: 20px;
 
-  th,
-  td {
-    padding: 15px;
-    padding-left: 15px;
-    border: 0 none;
+  dl {
+    display: flex;
     border-bottom: 1px solid lighten($ui-base-color, 4%);
-    vertical-align: middle;
   }
 
-  th {
-    padding-left: 15px;
-    font-weight: 500;
+  dt,
+  dd {
+    box-sizing: border-box;
+    padding: 14px;
     text-align: center;
-    width: 94px;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  dt {
+    font-weight: 500;
+    width: 120px;
+    flex: 0 0 auto;
     color: $secondary-text-color;
     background: rgba(darken($ui-base-color, 8%), 0.5);
   }
 
-  td {
+  dd {
+    flex: 1 1 auto;
     color: $darker-text-color;
-    text-align: center;
-    width: 100%;
-    padding-left: 0;
   }
 
   a {
@@ -608,12 +626,7 @@
     }
   }
 
-  tr {
-    &:last-child {
-      th,
-      td {
-        border-bottom: 0;
-      }
-    }
+  dl:last-child {
+    border-bottom: 0;
   }
 }
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index a6cc8b62b..560b11ddf 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -336,7 +336,8 @@
   }
 }
 
-.simple_form.new_report_note {
+.simple_form.new_report_note,
+.simple_form.new_account_moderation_note {
   max-width: 100%;
 }
 
@@ -483,19 +484,12 @@
 }
 
 a.name-tag,
-.name-tag {
-  display: flex;
-  align-items: center;
+.name-tag,
+a.inline-name-tag,
+.inline-name-tag {
   text-decoration: none;
   color: $secondary-text-color;
 
-  .avatar {
-    display: block;
-    margin: 0;
-    margin-right: 5px;
-    border-radius: 50%;
-  }
-
   .username {
     font-weight: 500;
   }
@@ -513,6 +507,26 @@ a.name-tag,
   }
 }
 
+a.name-tag,
+.name-tag {
+  display: flex;
+  align-items: center;
+
+  .avatar {
+    display: block;
+    margin: 0;
+    margin-right: 5px;
+    border-radius: 50%;
+  }
+
+  &.suspended {
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
 .speech-bubble {
   margin-bottom: 20px;
   border-left: 4px solid $ui-highlight-color;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index a982585c3..712b6f813 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -425,7 +425,7 @@
 
         .icon-button {
           flex: 0 1 auto;
-          color: $action-button-color;
+          color: $secondary-text-color;
           font-size: 14px;
           font-weight: 500;
           padding: 10px;
@@ -434,7 +434,7 @@
           &:hover,
           &:focus,
           &:active {
-            color: lighten($action-button-color, 7%);
+            color: lighten($secondary-text-color, 7%);
           }
         }
 
@@ -1373,9 +1373,8 @@ a.account__display-name {
 }
 
 .notification__message {
-  margin-left: 68px;
-  padding: 8px 0;
-  padding-bottom: 0;
+  margin: 0 10px 0 68px;
+  padding: 8px 0 0;
   cursor: default;
   color: $darker-text-color;
   font-size: 15px;
@@ -1984,6 +1983,7 @@ a.account__display-name {
   padding: 15px;
   margin: 0;
   z-index: 3;
+  outline: 0;
 
   &:hover {
     text-decoration: underline;
@@ -3181,6 +3181,7 @@ a.status-card {
   &.active {
     background: $ui-highlight-color;
     color: $primary-text-color;
+    outline: 0;
 
     .privacy-dropdown__option__content {
       color: $primary-text-color;
@@ -3283,6 +3284,15 @@ a.status-card {
 }
 
 .search__icon {
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus {
+    outline: 0 !important;
+  }
+
   .fa {
     position: absolute;
     top: 10px;
@@ -3332,40 +3342,33 @@ a.status-card {
 .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__section {
-  margin-bottom: 20px;
+  margin-bottom: 5px;
 
   h5 {
-    position: relative;
-
-    &::before {
-      content: "";
-      display: block;
-      position: absolute;
-      left: 0;
-      right: 0;
-      top: 50%;
-      width: 100%;
-      height: 0;
-      border-top: 1px solid lighten($ui-base-color, 8%);
-    }
+    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;
 
-    span {
+    .fa {
       display: inline-block;
-      background: $ui-base-color;
-      color: $darker-text-color;
-      font-size: 14px;
-      font-weight: 500;
-      padding: 10px;
-      position: relative;
-      z-index: 1;
-      cursor: default;
+      margin-right: 5px;
     }
   }
 
@@ -4033,7 +4036,7 @@ a.status-card {
 .report-modal__statuses {
   flex: 1 1 auto;
   min-height: 20vh;
-  max-height: 40vh;
+  max-height: 80vh;
   overflow-y: auto;
   overflow-x: hidden;
 
@@ -4432,6 +4435,10 @@ a.status-card {
   max-width: 100%;
   border-radius: 4px;
 
+  &:focus {
+    outline: 0;
+  }
+
   video {
     max-width: 100vw;
     max-height: 80vh;
@@ -4732,6 +4739,8 @@ a.status-card {
   }
 }
 
+.community-timeline__section-headline,
+.public-timeline__section-headline,
 .account__section-headline {
   background: darken($ui-base-color, 4%);
   border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -5159,39 +5168,124 @@ noscript {
   }
 }
 
+.account__header .roles {
+  margin-top: 20px;
+  margin-bottom: 20px;
+  padding: 0 15px;
+}
+
 .account__header .account__header__fields {
   font-size: 14px;
   line-height: 20px;
   overflow: hidden;
-  border-collapse: collapse;
   margin: 20px -10px -20px;
   border-bottom: 0;
 
-  tr {
+  dl {
     border-top: 1px solid lighten($ui-base-color, 8%);
-    text-align: center;
+    display: flex;
   }
 
-  th,
-  td {
+  dt,
+  dd {
+    box-sizing: border-box;
     padding: 14px 20px;
-    vertical-align: middle;
-    max-height: 40px;
+    text-align: center;
+    max-height: 48px;
     overflow: hidden;
     white-space: nowrap;
     text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     color: $darker-text-color;
     background: darken($ui-base-color, 4%);
-    max-width: 120px;
+    width: 120px;
+    flex: 0 0 auto;
     font-weight: 500;
   }
 
-  td {
-    flex: auto;
+  dd {
+    flex: 1 1 auto;
     color: $primary-text-color;
     background: $ui-base-color;
   }
 }
+
+.trends {
+  &__header {
+    color: $dark-text-color;
+    background: lighten($ui-base-color, 2%);
+    border-bottom: 1px solid darken($ui-base-color, 4%);
+    font-weight: 500;
+    padding: 15px;
+    font-size: 16px;
+    cursor: default;
+
+    .fa {
+      display: inline-block;
+      margin-right: 5px;
+    }
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    padding: 15px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__name {
+      flex: 1 1 auto;
+      color: $dark-text-color;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+
+      strong {
+        font-weight: 500;
+      }
+
+      a {
+        color: $darker-text-color;
+        text-decoration: none;
+        font-size: 14px;
+        font-weight: 500;
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+
+        &:hover,
+        &:focus,
+        &:active {
+          span {
+            text-decoration: underline;
+          }
+        }
+      }
+    }
+
+    &__current {
+      flex: 0 0 auto;
+      width: 100px;
+      font-size: 24px;
+      line-height: 36px;
+      font-weight: 500;
+      text-align: center;
+      color: $secondary-text-color;
+    }
+
+    &__sparkline {
+      flex: 0 0 auto;
+      width: 50px;
+
+      path {
+        stroke: lighten($highlight-text-color, 6%) !important;
+      }
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 9d5ab66a4..ac648c868 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -60,7 +60,7 @@
   }
 }
 
-.media-gallery-standalone__body {
+.media-standalone__body {
   overflow: hidden;
 }
 
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
index ba2a06954..fe2d40c0c 100644
--- a/app/javascript/styles/mastodon/footer.scss
+++ b/app/javascript/styles/mastodon/footer.scss
@@ -4,7 +4,7 @@
   font-size: 12px;
   color: $darker-text-color;
 
-  .domain {
+  .footer__domain {
     font-weight: 500;
 
     a {
@@ -26,5 +26,13 @@
         text-decoration: none;
       }
     }
+
+    img {
+      margin: 0 4px;
+      position: relative;
+      bottom: -1px;
+      height: 18px;
+      vertical-align: top;
+    }
   }
 }
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index f97890187..de16784a8 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -87,6 +87,10 @@ code {
       align-items: flex-start;
     }
 
+    &.file .label_input {
+      flex-wrap: nowrap;
+    }
+
     &.select .label_input {
       align-items: initial;
     }
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index fa876e603..982bfd990 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -1,3 +1,9 @@
+@keyframes Swag {
+  0% { background-position: 0% 0%; }
+  50% { background-position: 100% 0%; }
+  100% { background-position: 200% 0%; }
+}
+
 .table {
   width: 100%;
   max-width: 100%;
@@ -187,6 +193,11 @@ a.table-action-link {
 
     strong {
       font-weight: 700;
+      background: linear-gradient(to right, orange , yellow, green, cyan, blue, violet,orange , yellow, green, cyan, blue, violet);
+      background-size: 200% 100%;
+      background-clip: text;
+      color: transparent;
+      animation: Swag 2s linear 0s infinite;
     }
   }
 }
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
index cbefe35b4..40aeb4afc 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -1,10 +1,10 @@
 // Commonly used web colors
 $black: #000000;            // Black
 $white: #ffffff;            // White
-$success-green: #79bd9a;    // Padua
-$error-red: #df405a;        // Cerise
-$warning-red: #ff5050;      // Sunset Orange
-$gold-star: #ca8f04;        // Dark Goldenrod
+$success-green: #79bd9a !default;    // Padua
+$error-red: #df405a !default;        // Cerise
+$warning-red: #ff5050 !default;      // Sunset Orange
+$gold-star: #ca8f04 !default;        // Dark Goldenrod
 
 // Values from the classic Mastodon UI
 $classic-base-color: #282c37;         // Midnight Express
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 84d4b1752..03476920b 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -118,4 +118,13 @@ class ActivityPub::Activity
   def delete_later!(uri)
     redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
   end
+
+  def fetch_remote_original_status
+    if object_uri.start_with?('http')
+      return if ActivityPub::TagManager.instance.local_uri?(object_uri)
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first)
+    elsif @object['url'].present?
+      ::FetchRemoteStatusService.new.call(@object['url'])
+    end
+  end
 end
diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb
index ea94d2f98..688ab00b3 100644
--- a/app/lib/activitypub/activity/add.rb
+++ b/app/lib/activitypub/activity/add.rb
@@ -4,9 +4,10 @@ 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   = status_from_uri(object_uri)
+    status ||= fetch_remote_original_status
 
-    return unless status.account_id == @account.id && !@account.pinned?(status)
+    return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
 
     StatusPin.create!(account: @account, status: status)
   end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 7e146ea8c..1147a4481 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -26,16 +26,6 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
 
   private
 
-  def fetch_remote_original_status
-    if object_uri.start_with?('http')
-      return if ActivityPub::TagManager.instance.local_uri?(object_uri)
-
-      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true)
-    elsif @object['url'].present?
-      ::FetchRemoteStatusService.new.call(@object['url'])
-    end
-  end
-
   def announceable?(status)
     status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility?
   end
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
index f630d5db2..26da8bdf5 100644
--- a/app/lib/activitypub/activity/block.rb
+++ b/app/lib/activitypub/activity/block.rb
@@ -7,6 +7,6 @@ class ActivityPub::Activity::Block < ActivityPub::Activity
     return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account)
 
     UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
-    @account.block!(target_account)
+    @account.block!(target_account, uri: @json['id'])
   end
 end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 8d17a4ebe..869749f1e 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -11,6 +11,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       if lock.acquired?
         @status = find_existing_status
         process_status if @status.nil?
+      else
+        raise Mastodon::RaceConditionError
       end
     end
 
@@ -76,9 +78,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     return if tag['name'].blank?
 
     hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
-    hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+    hashtag = Tag.where(name: hashtag).first_or_create(name: hashtag)
+
+    return if status.tags.include?(hashtag)
 
     status.tags << hashtag
+    TrendingTags.record_use!(hashtag, status.account, status.created_at)
   rescue ActiveRecord::RecordInvalid
     nil
   end
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
index 8adbbb9c3..fbbf358a8 100644
--- a/app/lib/activitypub/activity/follow.rb
+++ b/app/lib/activitypub/activity/follow.rb
@@ -12,7 +12,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
       return
     end
 
-    follow_request = FollowRequest.create!(account: @account, target_account: target_account)
+    follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id'])
 
     if target_account.locked?
       NotifyService.new.call(target_account, follow_request)
diff --git a/app/lib/activitypub/activity/remove.rb b/app/lib/activitypub/activity/remove.rb
index 62a1e3196..f523ead9f 100644
--- a/app/lib/activitypub/activity/remove.rb
+++ b/app/lib/activitypub/activity/remove.rb
@@ -6,7 +6,7 @@ class ActivityPub::Activity::Remove < ActivityPub::Activity
 
     status = status_from_uri(object_uri)
 
-    return unless status.account_id == @account.id
+    return unless !status.nil? && status.account_id == @account.id
 
     pin = StatusPin.find_by(account: @account, status: status)
     pin&.destroy!
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index fa2a8f7d3..95d1cf9f3 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -38,6 +38,10 @@ class ActivityPub::TagManager
     end
   end
 
+  def generate_uri_for(_target)
+    URI.join(root_url, 'payloads', SecureRandom.uuid)
+  end
+
   def activity_uri_for(target)
     raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
 
@@ -82,6 +86,8 @@ class ActivityPub::TagManager
   end
 
   def local_uri?(uri)
+    return false if uri.nil?
+
     uri  = Addressable::URI.parse(uri)
     host = uri.normalized_host
     host = "#{host}:#{uri.port}" if uri.port
@@ -95,6 +101,8 @@ class ActivityPub::TagManager
   end
 
   def uri_to_resource(uri, klass)
+    return if uri.nil?
+
     if local_uri?(uri)
       case klass.name
       when 'Account'
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 050c651ee..e1ab05cc0 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -67,9 +67,17 @@ class Formatter
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
-  def format_field(account, str)
+  def format_display_name(account, **options)
+    html = encode(account.display_name.presence || account.username)
+    html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  def format_field(account, str, **options)
     return reformat(str).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
-    encode_and_link_urls(str, me: true).html_safe # rubocop:disable Rails/OutputSafety
+    html = encode_and_link_urls(str, me: true)
+    html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
+    html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
   def linkify(text)
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index 1e7f47029..d3a303a0c 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -15,6 +15,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
         @status = find_status(id)
         return [@status, false] unless @status.nil?
         @status = process_status
+      else
+        raise Mastodon::RaceConditionError
       end
     end
 
@@ -46,7 +48,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
         visibility: visibility_scope,
         conversation: find_or_create_conversation,
         thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil,
-        media_attachment_ids: media_attachments.map(&:id)
+        media_attachment_ids: media_attachments.map(&:id),
+        sensitive: sensitive?
       )
 
       save_mentions(status)
@@ -105,6 +108,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
 
   private
 
+  def sensitive?
+    # OStatus-specific convention (not standard)
+    @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).any? { |category| category['term'] == 'nsfw' }
+  end
+
   def find_or_create_conversation
     uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content
     return if uri.nil?
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 7c66f2066..5c6ff4f9b 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -354,7 +354,7 @@ class OStatus::AtomSerializer
     append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
     append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
 
-    status.mentions.order(:id).each do |mentioned|
+    status.mentions.sort_by(&:id).each do |mentioned|
       append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
     end
 
@@ -368,6 +368,7 @@ class OStatus::AtomSerializer
       append_element(entry, 'link', nil, rel: :enclosure, type: media.file_content_type, length: media.file_file_size, href: full_asset_url(media.file.url(:original, false)))
     end
 
+    append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive? && status.media_attachments.any?
     append_element(entry, 'mastodon:scope', status.visibility)
 
     status.emojis.each do |emoji|
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 00f94dacf..397614fac 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -57,10 +57,11 @@ class Request
   private
 
   def set_common_headers!
-    @headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
-    @headers['User-Agent']   = user_agent
-    @headers['Host']         = @url.host
-    @headers['Date']         = Time.now.utc.httpdate
+    @headers[REQUEST_TARGET]    = "#{@verb} #{@url.path}"
+    @headers['User-Agent']      = Mastodon::Version.user_agent
+    @headers['Host']            = @url.host
+    @headers['Date']            = Time.now.utc.httpdate
+    @headers['Accept-Encoding'] = 'gzip' if @verb != :head
   end
 
   def set_digest!
@@ -82,10 +83,6 @@ class Request
     @headers.keys.join(' ').downcase
   end
 
-  def user_agent
-    @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
-  end
-
   def key_id
     case @key_id_format
     when :acct
@@ -100,7 +97,7 @@ class Request
   end
 
   def http_client
-    @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2)
+    @http_client ||= HTTP.use(:auto_inflate).timeout(:per_operation, timeout).follow(max_hops: 2)
   end
 
   def use_proxy?
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 78b3aa77c..f8bacb036 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -30,6 +30,7 @@ class UserSettingsDecorator
     user.settings['noindex']                 = noindex_preference if change?('setting_noindex')
     user.settings['flavour']                 = flavour_preference if change?('setting_flavour')
     user.settings['skin']                    = skin_preference if change?('setting_skin')
+    user.settings['hide_network']            = hide_network_preference if change?('setting_hide_network')
   end
 
   def merged_notification_emails
@@ -92,6 +93,10 @@ class UserSettingsDecorator
     settings['setting_skin']
   end
 
+  def hide_network_preference
+    boolean_cast_setting 'setting_hide_network'
+  end
+
   def boolean_cast_setting(key)
     ActiveModel::Type::Boolean.new.cast(settings[key])
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index c1ce1e99e..48f284785 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -45,6 +45,7 @@
 #  moved_to_account_id     :bigint(8)
 #  featured_collection_url :string
 #  fields                  :jsonb
+#  actor_type              :string
 #
 
 class Account < ApplicationRecord
@@ -76,6 +77,7 @@ class Account < ApplicationRecord
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
   validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
   validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? }
+  validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
 
   # Timelines
   has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -137,6 +139,7 @@ class Account < ApplicationRecord
            :moderator?,
            :staff?,
            :locale,
+           :hides_network?,
            to: :user,
            prefix: true,
            allow_nil: true
@@ -151,6 +154,16 @@ class Account < ApplicationRecord
     moved_to_account_id.present?
   end
 
+  def bot?
+    %w(Application Service).include? actor_type
+  end
+
+  alias bot bot?
+
+  def bot=(val)
+    self.actor_type = ActiveModel::Type::Boolean.new.cast(val) ? 'Service' : 'Person'
+  end
+
   def acct
     local? ? username : "#{username}@#{domain}"
   end
@@ -201,9 +214,11 @@ class Account < ApplicationRecord
   def fields_attributes=(attributes)
     fields = []
 
-    attributes.each_value do |attr|
-      next if attr[:name].blank?
-      fields << attr
+    if attributes.is_a?(Hash)
+      attributes.each_value do |attr|
+        next if attr[:name].blank?
+        fields << attr
+      end
     end
 
     self[:fields] = fields
@@ -272,8 +287,8 @@ class Account < ApplicationRecord
 
     def initialize(account, attr)
       @account = account
-      @name    = attr['name']
-      @value   = attr['value']
+      @name    = attr['name'].strip[0, 255]
+      @value   = attr['value'].strip[0, 255]
       @errors  = {}
     end
 
@@ -398,7 +413,7 @@ class Account < ApplicationRecord
   end
 
   def emojis
-    @emojis ||= CustomEmoji.from_text(note, domain)
+    @emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
   end
 
   before_create :generate_keys
@@ -441,4 +456,8 @@ class Account < ApplicationRecord
 
     self.domain = TagManager.instance.normalize_domain(domain)
   end
+
+  def emojifiable_text
+    [note, display_name, fields.map(&:value)].join(' ')
+  end
 end
diff --git a/app/models/block.rb b/app/models/block.rb
index df4a6bbac..bf3e07600 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -8,6 +8,7 @@
 #  updated_at        :datetime         not null
 #  account_id        :bigint(8)        not null
 #  target_account_id :bigint(8)        not null
+#  uri               :string
 #
 
 class Block < ApplicationRecord
@@ -19,7 +20,12 @@ class Block < ApplicationRecord
 
   validates :account_id, uniqueness: { scope: :target_account_id }
 
+  def local?
+    false # Force uri_for to use uri attribute
+  end
+
   after_commit :remove_blocking_cache
+  before_validation :set_uri, only: :create
 
   private
 
@@ -27,4 +33,8 @@ class Block < ApplicationRecord
     Rails.cache.delete("exclude_account_ids_for:#{account_id}")
     Rails.cache.delete("exclude_account_ids_for:#{target_account_id}")
   end
+
+  def set_uri
+    self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
+  end
 end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 20fc74ba6..a064248d9 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -82,16 +82,19 @@ module AccountInteractions
     has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
   end
 
-  def follow!(other_account, reblogs: nil)
+  def follow!(other_account, reblogs: nil, uri: nil)
     reblogs = true if reblogs.nil?
-    rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account)
-    rel.update!(show_reblogs: reblogs)
 
+    rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri)
+                              .find_or_create_by!(target_account: other_account)
+
+    rel.update!(show_reblogs: reblogs)
     rel
   end
 
-  def block!(other_account)
-    block_relationships.find_or_create_by!(target_account: other_account)
+  def block!(other_account, uri: nil)
+    block_relationships.create_with(uri: uri)
+                       .find_or_create_by!(target_account: other_account)
   end
 
   def mute!(other_account, notifications: nil)
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 7f1ef5191..c17f04776 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -41,6 +41,9 @@ module Remotable
         rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
           Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
           nil
+        rescue Paperclip::Error, Mastodon::DimensionsValidationError => e
+          Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
+          nil
         end
       end
 
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index 8e817be00..1ba8fc693 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -74,16 +74,7 @@ module StatusThreadingConcern
     statuses    = statuses_with_accounts(ids).to_a
     account_ids = statuses.map(&:account_id).uniq
     domains     = statuses.map(&:account_domain).compact.uniq
-
-    relations = if account.present?
-                  {
-                    blocking: Account.blocking_map(account_ids, account.id),
-                    blocked_by: Account.blocked_by_map(account_ids, account.id),
-                    muting: Account.muting_map(account_ids, account.id),
-                    following: Account.following_map(account_ids, account.id),
-                    domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
-                  }
-                end
+    relations   = relations_map_for_account(account, account_ids, domains)
 
     statuses.reject! { |status| filter_from_context?(status, account, relations) }
 
@@ -91,6 +82,18 @@ module StatusThreadingConcern
     statuses.sort_by! { |status| ids.index(status.id) }
   end
 
+  def relations_map_for_account(account, account_ids, domains)
+    return {} if account.nil?
+
+    {
+      blocking: Account.blocking_map(account_ids, account.id),
+      blocked_by: Account.blocked_by_map(account_ids, account.id),
+      muting: Account.muting_map(account_ids, account.id),
+      following: Account.following_map(account_ids, account.id),
+      domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
+    }
+  end
+
   def statuses_with_accounts(ids)
     Status.where(id: ids).includes(:account)
   end
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 2ca42ff70..eaf8445f3 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -9,6 +9,7 @@
 #  account_id        :bigint(8)        not null
 #  target_account_id :bigint(8)        not null
 #  show_reblogs      :boolean          default(TRUE), not null
+#  uri               :string
 #
 
 class Follow < ApplicationRecord
@@ -26,4 +27,16 @@ class Follow < ApplicationRecord
   validates :account_id, uniqueness: { scope: :target_account_id }
 
   scope :recent, -> { reorder(id: :desc) }
+
+  def local?
+    false # Force uri_for to use uri attribute
+  end
+
+  before_validation :set_uri, only: :create
+
+  private
+
+  def set_uri
+    self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
+  end
 end
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index d559a8f62..9c4875564 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -9,6 +9,7 @@
 #  account_id        :bigint(8)        not null
 #  target_account_id :bigint(8)        not null
 #  show_reblogs      :boolean          default(TRUE), not null
+#  uri               :string
 #
 
 class FollowRequest < ApplicationRecord
@@ -23,11 +24,22 @@ class FollowRequest < ApplicationRecord
   validates :account_id, uniqueness: { scope: :target_account_id }
 
   def authorize!
-    account.follow!(target_account, reblogs: show_reblogs)
+    account.follow!(target_account, reblogs: show_reblogs, uri: uri)
     MergeWorker.perform_async(target_account.id, account.id)
-
     destroy!
   end
 
   alias reject! destroy!
+
+  def local?
+    false # Force uri_for to use uri attribute
+  end
+
+  before_validation :set_uri, only: :create
+
+  private
+
+  def set_uri
+    self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
+  end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 0b3a7c0aa..c6d6453df 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -195,12 +195,45 @@ class Status < ApplicationRecord
       where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
     end
 
-    def as_direct_timeline(account)
-      query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
-              .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
-              .where(visibility: [:direct])
+    def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
+      # direct timeline is mix of direct message from_me and to_me.
+      # 2 querys are executed with pagination.
+      # constant expression using arel_table is required for partial index
+
+      # _from_me part does not require any timeline filters
+      query_from_me = where(account_id: account.id)
+                      .where(Status.arel_table[:visibility].eq(3))
+                      .limit(limit)
+                      .order('statuses.id DESC')
+
+      # _to_me part requires mute and block filter.
+      # FIXME: may we check mutes.hide_notifications?
+      query_to_me = Status
+                    .joins(:mentions)
+                    .merge(Mention.where(account_id: account.id))
+                    .where(Status.arel_table[:visibility].eq(3))
+                    .limit(limit)
+                    .order('mentions.status_id DESC')
+                    .not_excluded_by_account(account)
+
+      if max_id.present?
+        query_from_me = query_from_me.where('statuses.id < ?', max_id)
+        query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
+      end
+
+      if since_id.present?
+        query_from_me = query_from_me.where('statuses.id > ?', since_id)
+        query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
+      end
 
-      apply_timeline_filters(query, account, false)
+      if cache_ids
+        # returns array of cache_ids object that have id and updated_at
+        (query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+      else
+        # returns ActiveRecord.Relation
+        items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+        Status.where(id: items.map(&:id))
+      end
     end
 
     def as_public_timeline(account = nil, local_only = false)
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 8b1b02412..4f31f796e 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -21,6 +21,22 @@ class Tag < ApplicationRecord
     name
   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
+  end
+
   class << self
     def search_for(term, limit = 5)
       pattern = sanitize_sql_like(term.strip) + '%'
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
new file mode 100644
index 000000000..eedd92644
--- /dev/null
+++ b/app/models/trending_tags.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class TrendingTags
+  KEY                  = 'trending_tags'
+  HALF_LIFE            = 1.day.to_i
+  MAX_ITEMS            = 500
+  EXPIRE_HISTORY_AFTER = 7.days.seconds
+
+  class << self
+    def record_use!(tag, account, at_time = Time.now.utc)
+      return if disallowed_hashtags.include?(tag.name) || account.silenced?
+
+      increment_vote!(tag.id, at_time)
+      increment_historical_use!(tag.id, at_time)
+      increment_unique_use!(tag.id, account.id, at_time)
+    end
+
+    def get(limit)
+      tag_ids = redis.zrevrange(KEY, 0, limit).map(&:to_i)
+      tags    = Tag.where(id: tag_ids).to_a.map { |tag| [tag.id, tag] }.to_h
+      tag_ids.map { |tag_id| tags[tag_id] }.compact
+    end
+
+    private
+
+    def increment_vote!(tag_id, at_time)
+      redis.zincrby(KEY, (2**((at_time.to_i - epoch) / HALF_LIFE)).to_f, tag_id.to_s)
+      redis.zremrangebyrank(KEY, 0, -MAX_ITEMS) if rand < (2.to_f / MAX_ITEMS)
+    end
+
+    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
+
+    # The epoch needs to be 2.5 years in the future if the half-life is one day
+    # While dynamic, it will always be the same within one year
+    def epoch
+      @epoch ||= Date.new(Date.current.year + 2.5, 10, 1).to_datetime.to_i
+    end
+
+    def disallowed_hashtags
+      return @disallowed_hashtags if defined?(@disallowed_hashtags)
+
+      @disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
+      @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
+      @disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
+    end
+
+    def redis
+      Redis.current
+    end
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 24beb77b2..ef48282fd 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,7 +41,7 @@ class User < ApplicationRecord
   include Settings::Extend
   include Omniauthable
 
-  ACTIVE_DURATION = 14.days
+  ACTIVE_DURATION = 7.days
 
   devise :two_factor_authenticatable,
          otp_secret_encryption_key: Rails.configuration.x.otp_secret
@@ -65,6 +65,7 @@ class User < ApplicationRecord
 
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
+  validates_with EmailMxValidator, if: :email_changed?
 
   scope :recent, -> { order(id: :desc) }
   scope :admins, -> { where(admin: true) }
@@ -86,7 +87,7 @@ class User < ApplicationRecord
   has_many :session_activations, dependent: :destroy
 
   delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
-           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_sensitive_media,
+           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_sensitive_media, :hide_network,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_accessor :invite_code
@@ -219,6 +220,10 @@ class User < ApplicationRecord
     settings.notification_emails['digest']
   end
 
+  def hides_network?
+    @hides_network ||= settings.hide_network
+  end
+
   def token_for_app(a)
     return nil if a.nil? || a.owner != self
     Doorkeeper::AccessToken
@@ -245,7 +250,7 @@ class User < ApplicationRecord
   end
 
   def web_push_subscription(session)
-    session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload
+    session.web_push_subscription.nil? ? nil : session.web_push_subscription
   end
 
   def invite_code=(code)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index 1736106f7..867bc9519 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -3,46 +3,65 @@
 #
 # Table name: web_push_subscriptions
 #
-#  id         :bigint(8)        not null, primary key
-#  endpoint   :string           not null
-#  key_p256dh :string           not null
-#  key_auth   :string           not null
-#  data       :json
-#  created_at :datetime         not null
-#  updated_at :datetime         not null
+#  id              :bigint(8)        not null, primary key
+#  endpoint        :string           not null
+#  key_p256dh      :string           not null
+#  key_auth        :string           not null
+#  data            :json
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#  access_token_id :bigint(8)
+#  user_id         :bigint(8)
 #
 
-require 'webpush'
-
 class Web::PushSubscription < ApplicationRecord
+  belongs_to :user, optional: true
+  belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', optional: true
+
   has_one :session_activation
 
   def push(notification)
-    I18n.with_locale(session_activation.user.locale || I18n.default_locale) do
-      push_payload(message_from(notification), 48.hours.seconds)
+    I18n.with_locale(associated_user&.locale || I18n.default_locale) do
+      push_payload(payload_for_notification(notification), 48.hours.seconds)
     end
   end
 
   def pushable?(notification)
-    data&.key?('alerts') && data['alerts'][notification.type.to_s]
+    data&.key?('alerts') && ActiveModel::Type::Boolean.new.cast(data['alerts'][notification.type.to_s])
   end
 
-  def as_payload
-    payload = { id: id, endpoint: endpoint }
-    payload[:alerts] = data['alerts'] if data&.key?('alerts')
-    payload
+  def associated_user
+    return @associated_user if defined?(@associated_user)
+
+    @associated_user = if user_id.nil?
+                         session_activation.user
+                       else
+                         user
+                       end
   end
 
-  def access_token
-    find_or_create_access_token.token
+  def associated_access_token
+    return @associated_access_token if defined?(@associated_access_token)
+
+    @associated_access_token = if access_token_id.nil?
+                                 find_or_create_access_token.token
+                               else
+                                 access_token.token
+                               end
+  end
+
+  class << self
+    def unsubscribe_for(application_id, resource_owner)
+      access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil)
+                                                .pluck(:id)
+
+      where(access_token_id: access_token_ids).delete_all
+    end
   end
 
   private
 
   def push_payload(message, ttl = 5.minutes.seconds)
-    # TODO: Make sure that the payload does not
-    # exceed 4KB - Webpush::PayloadTooLarge
-
     Webpush.payload_send(
       message: Oj.dump(message),
       endpoint: endpoint,
@@ -57,16 +76,20 @@ class Web::PushSubscription < ApplicationRecord
     )
   end
 
-  def message_from(notification)
-    serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription)
-    serializable_resource.as_json
+  def payload_for_notification(notification)
+    ActiveModelSerializers::SerializableResource.new(
+      notification,
+      serializer: Web::NotificationSerializer,
+      scope: self,
+      scope_name: :current_push_subscription
+    ).as_json
   end
 
   def find_or_create_access_token
     Doorkeeper::AccessToken.find_or_create_for(
       Doorkeeper::Application.find_by(superapp: true),
       session_activation.user_id,
-      Doorkeeper::OAuth::Scopes.from_string('read write follow'),
+      Doorkeeper::OAuth::Scopes.from_string('read write follow push'),
       Doorkeeper.configuration.access_token_expires_in,
       Doorkeeper.configuration.refresh_token_enabled?
     )
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index fcf3bdf17..41c9aa44e 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -37,7 +37,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def type
-    'Person'
+    object.bot? ? 'Service' : 'Person'
   end
 
   def following
diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb
index b3bd9f868..624ce2fce 100644
--- a/app/serializers/activitypub/block_serializer.rb
+++ b/app/serializers/activitypub/block_serializer.rb
@@ -5,7 +5,7 @@ class ActivityPub::BlockSerializer < ActiveModel::Serializer
   attribute :virtual_object, key: :object
 
   def id
-    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
+    ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
   end
 
   def type
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index 86c9992fe..bb204ee8f 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -5,7 +5,7 @@ class ActivityPub::FollowSerializer < ActiveModel::Serializer
   attribute :virtual_object, key: :object
 
   def id
-    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
+    ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
   end
 
   def type
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 1d17e2b0a..4f2f4e38a 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -2,19 +2,15 @@
 
 class InitialStateSerializer < ActiveModel::Serializer
   attributes :meta, :compose, :accounts,
-             :media_attachments, :settings, :push_subscription,
+             :media_attachments, :settings,
              :max_toot_chars
 
-  has_many :custom_emojis, serializer: REST::CustomEmojiSerializer
+  has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
 
   def max_toot_chars
     StatusLengthValidator::MAX_CHARS
   end
 
-  def custom_emojis
-    CustomEmoji.local.where(disabled: false)
-  end
-
   def meta
     store = {
       streaming_api_base_url: Rails.configuration.x.streaming_api_base_url,
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 863238eb7..6adcd7039 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -3,11 +3,12 @@
 class REST::AccountSerializer < ActiveModel::Serializer
   include RoutingHelper
 
-  attributes :id, :username, :acct, :display_name, :locked, :created_at,
+  attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
              :note, :url, :avatar, :avatar_static, :header, :header_static,
              :followers_count, :following_count, :statuses_count
 
   has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
+  has_many :emojis, serializer: REST::CustomEmojiSerializer
 
   class FieldSerializer < ActiveModel::Serializer
     attributes :name, :value
diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb
new file mode 100644
index 000000000..74aa571a4
--- /dev/null
+++ b/app/serializers/rest/tag_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class REST::TagSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :name, :url, :history
+
+  def url
+    tag_url(object)
+  end
+end
diff --git a/app/serializers/rest/v2/search_serializer.rb b/app/serializers/rest/v2/search_serializer.rb
new file mode 100644
index 000000000..cdb6b3a53
--- /dev/null
+++ b/app/serializers/rest/v2/search_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class REST::V2::SearchSerializer < ActiveModel::Serializer
+  has_many :accounts, serializer: REST::AccountSerializer
+  has_many :statuses, serializer: REST::StatusSerializer
+  has_many :hashtags, serializer: REST::TagSerializer
+end
diff --git a/app/serializers/rest/web_push_subscription_serializer.rb b/app/serializers/rest/web_push_subscription_serializer.rb
new file mode 100644
index 000000000..7fd952a56
--- /dev/null
+++ b/app/serializers/rest/web_push_subscription_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer
+  attributes :id, :endpoint, :alerts, :server_key
+
+  def alerts
+    object.data&.dig('alerts') || {}
+  end
+
+  def server_key
+    Rails.configuration.x.vapid_public_key
+  end
+end
diff --git a/app/serializers/web/notification_serializer.rb b/app/serializers/web/notification_serializer.rb
index e5524fe7a..43ba4d92a 100644
--- a/app/serializers/web/notification_serializer.rb
+++ b/app/serializers/web/notification_serializer.rb
@@ -2,168 +2,38 @@
 
 class Web::NotificationSerializer < ActiveModel::Serializer
   include RoutingHelper
-  include StreamEntriesHelper
+  include ActionView::Helpers::TextHelper
+  include ActionView::Helpers::SanitizeHelper
 
-  class DataSerializer < ActiveModel::Serializer
-    include RoutingHelper
-    include StreamEntriesHelper
-    include ActionView::Helpers::SanitizeHelper
+  attributes :access_token, :preferred_locale, :notification_id,
+             :notification_type, :icon, :title, :body
 
-    attributes :content, :nsfw, :url, :actions,
-               :access_token, :message, :dir
-
-    def content
-      decoder.decode(strip_tags(body))
-    end
-
-    def dir
-      rtl?(body) ? 'rtl' : 'ltr'
-    end
-
-    def nsfw
-      return if object.target_status.nil?
-      object.target_status.spoiler_text.presence
-    end
-
-    def url
-      case object.type
-      when :mention
-        web_url("statuses/#{object.target_status.id}")
-      when :follow
-        web_url("accounts/#{object.from_account.id}")
-      when :favourite
-        web_url("statuses/#{object.target_status.id}")
-      when :reblog
-        web_url("statuses/#{object.target_status.id}")
-      end
-    end
-
-    def actions
-      return @actions if defined?(@actions)
-
-      @actions = []
-
-      if object.type == :mention
-        @actions << expand_action if collapsed?
-        @actions << favourite_action
-        @actions << reblog_action if rebloggable?
-      end
-
-      @actions
-    end
-
-    def access_token
-      return if actions.empty?
-      current_push_subscription.access_token
-    end
-
-    def message
-      I18n.t('push_notifications.group.title')
-    end
-
-    private
-
-    def body
-      case object.type
-      when :mention
-        object.target_status.text
-      when :follow
-        object.from_account.note
-      when :favourite
-        object.target_status.text
-      when :reblog
-        object.target_status.text
-      end
-    end
-
-    def decoder
-      @decoder ||= HTMLEntities.new
-    end
-
-    def expand_action
-      {
-        title: I18n.t('push_notifications.mention.action_expand'),
-        icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true),
-        todo: 'expand',
-        action: 'expand',
-      }
-    end
-
-    def favourite_action
-      {
-        title: I18n.t('push_notifications.mention.action_favourite'),
-        icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
-        todo: 'request',
-        method: 'POST',
-        action: "/api/v1/statuses/#{object.target_status.id}/favourite",
-      }
-    end
-
-    def reblog_action
-      {
-        title: I18n.t('push_notifications.mention.action_boost'),
-        icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true),
-        todo: 'request',
-        method: 'POST',
-        action: "/api/v1/statuses/#{object.target_status.id}/reblog",
-      }
-    end
-
-    def collapsed?
-      !object.target_status.nil? && (object.target_status.sensitive? || object.target_status.spoiler_text.present?)
-    end
-
-    def rebloggable?
-      !object.target_status.nil? && !object.target_status.hidden?
-    end
+  def access_token
+    current_push_subscription.associated_access_token
   end
 
-  attributes :title, :image, :badge, :tag,
-             :timestamp, :icon
-
-  has_one :data, serializer: DataSerializer
-
-  def title
-    case object.type
-    when :mention
-      I18n.t('push_notifications.mention.title', name: name)
-    when :follow
-      I18n.t('push_notifications.follow.title', name: name)
-    when :favourite
-      I18n.t('push_notifications.favourite.title', name: name)
-    when :reblog
-      I18n.t('push_notifications.reblog.title', name: name)
-    end
+  def preferred_locale
+    current_push_subscription.associated_user&.locale || I18n.default_locale
   end
 
-  def image
-    return if object.target_status.nil? || object.target_status.media_attachments.empty?
-    full_asset_url(object.target_status.media_attachments.first.file.url(:small))
-  end
-
-  def badge
-    full_asset_url('badge.png', skip_pipeline: true)
-  end
-
-  def tag
+  def notification_id
     object.id
   end
 
-  def timestamp
-    object.created_at
+  def notification_type
+    object.type
   end
 
   def icon
-    object.from_account.avatar_static_url
+    full_asset_url(object.from_account.avatar_static_url)
   end
 
-  def data
-    object
+  def title
+    I18n.t("notification_mailer.#{object.type}.subject", name: object.from_account.display_name.presence || object.from_account.username)
   end
 
-  private
-
-  def name
-    display_name(object.from_account)
+  def body
+    str = truncate(strip_tags(object.target_status&.spoiler_text&.presence || object.target_status&.text || object.from_account.note), length: 140)
+    HTMLEntities.new.decode(str.to_str) # Do not encode entities, since this value will not be used in HTML
   end
 end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index 930fbad1f..2b447abb3 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -4,9 +4,9 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   include JsonLdHelper
 
   # Should be called when uri has already been checked for locality
-  def call(uri, id: true, prefetched_body: nil)
+  def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil)
     @json = if prefetched_body.nil?
-              fetch_resource(uri, id)
+              fetch_resource(uri, id, on_behalf_of)
             else
               body_to_json(prefetched_body)
             end
@@ -34,6 +34,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   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?
   end
 
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index f67ebb443..453253db4 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -23,6 +23,8 @@ class ActivityPub::ProcessAccountService < BaseService
         create_account if @account.nil?
         update_account
         process_tags
+      else
+        raise Mastodon::RaceConditionError
       end
     end
 
@@ -44,7 +46,6 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.protocol    = :activitypub
     @account.username    = @username
     @account.domain      = @domain
-    @account.uri         = @uri
     @account.suspended   = true if auto_suspend?
     @account.silenced    = true if auto_silence?
     @account.private_key = nil
@@ -67,10 +68,12 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.followers_url           = @json['followers'] || ''
     @account.featured_collection_url = @json['featured'] || ''
     @account.url                     = url || @uri
+    @account.uri                     = @uri
     @account.display_name            = @json['name'] || ''
     @account.note                    = @json['summary'] || ''
     @account.locked                  = @json['manuallyApprovesFollowers'] || false
     @account.fields                  = property_values || {}
+    @account.actor_type              = actor_type
   end
 
   def set_fetchable_attributes!
@@ -95,6 +98,14 @@ class ActivityPub::ProcessAccountService < BaseService
     ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
   end
 
+  def actor_type
+    if @json['type'].is_a?(Array)
+      @json['type'].find { |type| ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(type) }
+    else
+      @json['type']
+    end
+  end
+
   def image_url(key)
     value = first_of_value(@json[key])
 
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
index eb93329e9..79cdca297 100644
--- a/app/services/activitypub/process_collection_service.rb
+++ b/app/services/activitypub/process_collection_service.rb
@@ -45,5 +45,8 @@ class ActivityPub::ProcessCollectionService < BaseService
 
   def verify_account!
     @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+  rescue JSON::LD::JsonLdError => e
+    Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}"
+    nil
   end
 end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index cb65a2256..ace51a1fc 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -81,6 +81,11 @@ class BatchedRemoveStatusService < BaseService
       redis.publish('timeline:public', payload)
       redis.publish('timeline:public:local', payload) if status.local?
 
+      if status.media_attachments.any?
+        redis.publish('timeline:public:media', payload)
+        redis.publish('timeline:public:local:media', payload) if status.local?
+      end
+
       @tags[status.id].each do |hashtag|
         redis.publish("timeline:hashtag:#{hashtag}", payload)
         redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 510b80c82..8b3630229 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -25,6 +25,7 @@ class FanOutOnWriteService < BaseService
     return if status.reply? && status.in_reply_to_account_id != status.account_id
 
     deliver_to_public(status)
+    deliver_to_media(status) if status.media_attachments.any?
   end
 
   private
@@ -37,7 +38,7 @@ class FanOutOnWriteService < BaseService
   def deliver_to_followers(status)
     Rails.logger.debug "Delivering status #{status.id} to followers"
 
-    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |followers|
+    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).select(:id).reorder(nil).find_in_batches do |followers|
       FeedInsertWorker.push_bulk(followers) do |follower|
         [status.id, follower.id, :home]
       end
@@ -47,7 +48,7 @@ class FanOutOnWriteService < BaseService
   def deliver_to_lists(status)
     Rails.logger.debug "Delivering status #{status.id} to lists"
 
-    status.account.lists.joins(account: :user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |lists|
+    status.account.lists.joins(account: :user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).select(:id).reorder(nil).find_in_batches do |lists|
       FeedInsertWorker.push_bulk(lists) do |list|
         [status.id, list.id, :list]
       end
@@ -85,6 +86,13 @@ class FanOutOnWriteService < BaseService
     Redis.current.publish('timeline:public:local', @payload) if status.local?
   end
 
+  def deliver_to_media(status)
+    Rails.logger.debug "Delivering status #{status.id} to media timeline"
+
+    Redis.current.publish('timeline:public:media', @payload)
+    Redis.current.publish('timeline:public:local:media', @payload) if status.local?
+  end
+
   def deliver_to_direct_timelines(status)
     Rails.logger.debug "Delivering status #{status.id} to direct timelines"
 
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 77d4aa538..86d0f9971 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -23,11 +23,13 @@ class FetchLinkCardService < BaseService
       if lock.acquired?
         @card = PreviewCard.find_by(url: @url)
         process_url if @card.nil? || @card.updated_at <= 2.weeks.ago
+      else
+        raise Mastodon::RaceConditionError
       end
     end
 
     attach_card if @card&.persisted?
-  rescue HTTP::Error, Addressable::URI::InvalidURIError => e
+  rescue HTTP::Error, Addressable::URI::InvalidURIError, Mastodon::LengthValidationError => e
     Rails.logger.debug "Error fetching link #{@url}: #{e}"
     nil
   end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index ba086449c..6490d2735 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -9,6 +9,7 @@ class NotifyService < BaseService
     return if recipient.user.nil? || blocked?
 
     create_notification
+    push_notification if @notification.browserable?
     send_email if email_enabled?
   rescue ActiveRecord::RecordInvalid
     return
@@ -101,25 +102,27 @@ class NotifyService < BaseService
 
   def create_notification
     @notification.save!
-    return unless @notification.browserable?
+  end
+
+  def push_notification
+    return if @notification.activity.nil?
+
     Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
     send_push_notifications
   end
 
   def send_push_notifications
-    # HACK: Can be caused by quickly unfavouriting a status, since creating
-    # a favourite and creating a notification are not wrapped in a transaction.
-    return if @notification.activity.nil?
-
-    sessions_with_subscriptions = @recipient.user.session_activations.where.not(web_push_subscription: nil)
-    sessions_with_subscriptions_ids = sessions_with_subscriptions.select { |session| session.web_push_subscription.pushable? @notification }.map(&:id)
+    subscriptions_ids = ::Web::PushSubscription.where(user_id: @recipient.user.id)
+                                               .select { |subscription| subscription.pushable?(@notification) }
+                                               .map(&:id)
 
-    WebPushNotificationWorker.push_bulk(sessions_with_subscriptions_ids) do |session_activation_id|
-      [session_activation_id, @notification.id]
+    ::Web::PushNotificationWorker.push_bulk(subscriptions_ids) do |subscription_id|
+      [subscription_id, @notification.id]
     end
   end
 
   def send_email
+    return if @notification.activity.nil?
     NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later
   end
 
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index fe03c044c..b1d5bd3a7 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -22,7 +22,7 @@ class PostStatusService < BaseService
     media  = validate_media!(options[:media_ids])
     status = nil
     text   = options.delete(:spoiler_text) if text.blank? && options[:spoiler_text].present?
-    text   = '.' if text.blank? && !media.empty?
+    text   = '.' if text.blank? && media.present?
 
     ApplicationRecord.transaction do
       status = account.statuses.create!(text: text,
@@ -31,12 +31,12 @@ class PostStatusService < BaseService
                                         sensitive: (options[:sensitive].nil? ? account.user&.setting_default_sensitive : options[:sensitive]),
                                         spoiler_text: options[:spoiler_text] || '',
                                         visibility: options[:visibility] || account.user&.setting_default_privacy,
-                                        language: LanguageDetector.instance.detect(text, account),
+                                        language: language_from_option(options[:language]) || LanguageDetector.instance.detect(text, account),
                                         application: options[:application])
     end
 
-    process_mentions_service.call(status)
     process_hashtags_service.call(status)
+    process_mentions_service.call(status)
 
     LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
     DistributionWorker.perform_async(status.id)
@@ -68,6 +68,10 @@ class PostStatusService < BaseService
     media
   end
 
+  def language_from_option(str)
+    ISO_639.find(str)&.alpha2
+  end
+
   def process_mentions_service
     ProcessMentionsService.new
   end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 5b45c865f..0695922b8 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -4,8 +4,10 @@ class ProcessHashtagsService < BaseService
   def call(status, tags = [])
     tags = Extractor.extract_hashtags(status.text) if status.local?
 
-    tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag|
-      status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
+    tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
+      tag = Tag.where(name: name).first_or_create(name: name)
+      status.tags << tag
+      TrendingTags.record_use!(tag, status.account, status.created_at)
     end
   end
 end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index e164c03ab..8c3e18444 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -20,6 +20,7 @@ class RemoveStatusService < BaseService
     remove_reblogs
     remove_from_hashtags
     remove_from_public
+    remove_from_media if status.media_attachments.any?
     remove_from_direct if status.direct_visibility?
 
     @status.destroy!
@@ -131,6 +132,13 @@ class RemoveStatusService < BaseService
     Redis.current.publish('timeline:public:local', @payload) if @status.local?
   end
 
+  def remove_from_media
+    return unless @status.public_visibility?
+
+    Redis.current.publish('timeline:public:media', @payload)
+    Redis.current.publish('timeline:public:local:media', @payload) if @status.local?
+  end
+
   def remove_from_direct
     @mentions.each do |mention|
       Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index de8d1151d..4323e7f06 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -49,6 +49,8 @@ class ResolveAccountService < BaseService
         else
           handle_ostatus
         end
+      else
+        raise Mastodon::RaceConditionError
       end
     end
 
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index aca1185de..68d36addf 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -41,24 +41,24 @@ class UpdateRemoteProfileService < BaseService
         account.header.destroy
       end
 
-      save_emojis(account) if remote_profile.emojis.present?
+      save_emojis if remote_profile.emojis.present?
     end
   end
 
-  def save_emojis(parent)
-    do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
+  def save_emojis
+    do_not_download = DomainBlock.find_by(domain: account.domain)&.reject_media?
 
     return if do_not_download
 
-    remote_account.emojis.each do |link|
+    remote_profile.emojis.each do |link|
       next unless link['href'] && link['name']
 
       shortcode = link['name'].delete(':')
-      emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: parent.account.domain)
+      emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: account.domain)
 
       next unless emoji.nil?
 
-      emoji = CustomEmoji.new(shortcode: shortcode, domain: parent.account.domain)
+      emoji = CustomEmoji.new(shortcode: shortcode, domain: account.domain)
       emoji.image_remote_url = link['href']
       emoji.save
     end
diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
new file mode 100644
index 000000000..3cc5853c6
--- /dev/null
+++ b/app/validators/email_mx_validator.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'resolv'
+
+class EmailMxValidator < ActiveModel::Validator
+  def validate(user)
+    return if Rails.env.test? || Rails.env.development?
+    user.errors.add(:email, I18n.t('users.invalid_email')) if invalid_mx?(user.email)
+  end
+
+  private
+
+  def invalid_mx?(value)
+    _, domain = value.split('@', 2)
+
+    return true if domain.nil?
+
+    records = Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
+    records.empty? || on_blacklist?(records)
+  end
+
+  def on_blacklist?(values)
+    EmailDomainBlock.where(domain: values).any?
+  end
+end
diff --git a/app/views/about/_administration.html.haml b/app/views/about/_administration.html.haml
index ec5834f9c..02286d68b 100644
--- a/app/views/about/_administration.html.haml
+++ b/app/views/about/_administration.html.haml
@@ -6,7 +6,7 @@
           .account__avatar{ style: "background-image: url(#{@instance_presenter.contact_account.avatar.url})" }
         %span.display-name
           %bdi
-            %strong.display-name__html.emojify= display_name(@instance_presenter.contact_account)
+            %strong.display-name__html.emojify= display_name(@instance_presenter.contact_account, custom_emojify: true)
           %span.display-name__account @#{@instance_presenter.contact_account.acct}
     - else
       .account__display-name
diff --git a/app/views/about/_contact.html.haml b/app/views/about/_contact.html.haml
index cf21ad5a3..3215d50b5 100644
--- a/app/views/about/_contact.html.haml
+++ b/app/views/about/_contact.html.haml
@@ -12,7 +12,7 @@
         .avatar= image_tag contact.contact_account.avatar.url
         .name
           = link_to TagManager.instance.url_for(contact.contact_account) do
-            %span.display_name.emojify= display_name(contact.contact_account)
+            %span.display_name.emojify= display_name(contact.contact_account, custom_emojify: true)
             %span.username @#{contact.contact_account.acct}
     - else
       .owner
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index e264c8574..e6d4cd10e 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -141,3 +141,5 @@
             %p
               = link_to t('about.source_code'), @instance_presenter.source_url
               = " (#{@instance_presenter.version_number})"
+
+#modal-container
diff --git a/app/views/accounts/_follow_grid.html.haml b/app/views/accounts/_follow_grid.html.haml
index a6d0ee817..fdcef84be 100644
--- a/app/views/accounts/_follow_grid.html.haml
+++ b/app/views/accounts/_follow_grid.html.haml
@@ -1,5 +1,6 @@
-.accounts-grid
+.accounts-grid{ class: accounts.empty? ? 'empty' : '' }
   - if accounts.empty?
+    = image_tag asset_pack_path('elephant_ui_greeting.svg'), alt: '', role: 'presentational'
     = render partial: 'accounts/nothing_here'
   - else
     = render partial: 'accounts/grid_card', collection: accounts, as: :account, cached: !user_signed_in?
diff --git a/app/views/accounts/_follow_grid_hidden.html.haml b/app/views/accounts/_follow_grid_hidden.html.haml
new file mode 100644
index 000000000..e970350e6
--- /dev/null
+++ b/app/views/accounts/_follow_grid_hidden.html.haml
@@ -0,0 +1,3 @@
+.accounts-grid.empty
+  = image_tag asset_pack_path('elephant_ui_greeting.svg'), alt: '', role: 'presentational'
+  %p.nothing-here= t('accounts.network_hidden')
diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml
index 95acbd581..a59ed128e 100644
--- a/app/views/accounts/_grid_card.html.haml
+++ b/app/views/accounts/_grid_card.html.haml
@@ -5,7 +5,7 @@
     .avatar= image_tag account.avatar.url(:original)
   .name
     = link_to TagManager.instance.url_for(account) do
-      %span.display_name.emojify= display_name(account)
+      %span.display_name.emojify= display_name(account, custom_emojify: true)
       %span.username
         @#{account.local? ? account.local_username_and_domain : account.acct}
         = fa_icon('lock') if account.locked?
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index af79922c2..b5653f161 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -6,11 +6,16 @@
 
   .card__bio
     %h1.name
-      %span.p-name.emojify= display_name(account)
+      %span.p-name.emojify= display_name(account, custom_emojify: true)
       %small<
         %span>< @#{account.local_username_and_domain}
         = fa_icon('lock') if account.locked?
-    - if Setting.show_staff_badge
+
+    - if account.bot?
+      .roles
+        .account-role.bot
+          = t 'accounts.roles.bot'
+    - elsif Setting.show_staff_badge
       - if account.user_admin?
         .roles
           .account-role.admin
@@ -21,19 +26,19 @@
             = t 'accounts.roles.moderator'
     .bio
       .account__header__content.p-note.emojify!=processed_bio[:text]
+
       - if !account.fields.empty?
-        %table.account__header__fields
-          %tbody
-            - account.fields.each do |field|
-              %tr
-                %th.emojify= field.name
-                %td.emojify= Formatter.instance.format_field(account, field.value)
+        .account__header__fields
+          - account.fields.each do |field|
+            %dl
+              %dt.emojify{ title: field.name }= field.name
+              %dd.emojify{ title: field.value }= Formatter.instance.format_field(account, field.value, custom_emojify: true)
       - elsif processed_bio[:metadata].length > 0
-        %table.account__header__fields<
+        .account__header__fields
           - processed_bio[:metadata].each do |i|
-            %tr
-              %th.emojify>!=i[0]
-              %td.emojify>!=i[1]
+            %dl
+              %dt.emojify{ title: i[0] }!= i[0]
+              %dd.emojify{ title: i[1] }!= i[1]
 
     .details-counters
       .counter{ class: active_nav_class(short_account_url(account)) }
diff --git a/app/views/accounts/_moved_strip.html.haml b/app/views/accounts/_moved_strip.html.haml
index 6a14a5dd3..ae18c6dc7 100644
--- a/app/views/accounts/_moved_strip.html.haml
+++ b/app/views/accounts/_moved_strip.html.haml
@@ -3,7 +3,7 @@
 .moved-strip
   .moved-strip__message
     = fa_icon 'suitcase'
-    = t('accounts.moved_html', name: content_tag(:strong, display_name(account), class: :emojify), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.acct)])), TagManager.instance.url_for(moved_to_account), class: 'mention'))
+    = t('accounts.moved_html', name: content_tag(:strong, display_name(account, custom_emojify: true), class: :emojify), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.acct)])), TagManager.instance.url_for(moved_to_account), class: 'mention'))
 
   .moved-strip__card
     = link_to TagManager.instance.url_for(moved_to_account), class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
@@ -13,5 +13,5 @@
           .account__avatar-overlay-overlay{ style: "background-image: url('#{account.avatar.url(:original)}')" }
 
       %span.display-name
-        %strong.emojify= display_name(moved_to_account)
+        %strong.emojify= display_name(moved_to_account, custom_emojify: true)
         %span @#{moved_to_account.acct}
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index bbf2139a5..cfdd3a945 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -8,6 +8,7 @@
     %meta{ name: 'robots', content: 'noindex' }/
 
   %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
+  %link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
   %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/
 
diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
index 6761a4319..432fb79a6 100644
--- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
+++ b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
@@ -1,10 +1,7 @@
-%tr
-  %td
+.speech-bubble
+  .speech-bubble__bubble
     = simple_format(h(account_moderation_note.content))
-  %td
-    = account_moderation_note.account.acct
-  %td
-    %time.formatted{ datetime: account_moderation_note.created_at.iso8601, title: l(account_moderation_note.created_at) }
-      = l account_moderation_note.created_at
-  %td
-    = link_to t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
+  .speech-bubble__owner
+    = admin_account_link_to account_moderation_note.account
+    %time.formatted{ datetime: account_moderation_note.created_at.iso8601 }= l account_moderation_note.created_at
+    = table_link_to 'trash', t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 7312618ee..ed8190af5 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -2,7 +2,7 @@
   = @account.acct
 
 .table-wrapper
-  %table.table
+  %table.table.inline-table
     %tbody
       %tr
         %th= t('admin.accounts.username')
@@ -36,14 +36,20 @@
           %th= t('admin.accounts.email')
           %td
             = @account.user_email
-            - if @account.user_confirmed?
-              = fa_icon('check')
             = table_link_to 'edit', t('admin.accounts.change_email.label'), admin_account_change_email_path(@account.id) if can?(:change_email, @account.user)
         - if @account.user_unconfirmed_email.present?
           %th= t('admin.accounts.unconfirmed_email')
           %td
             = @account.user_unconfirmed_email
         %tr
+          %th= t('admin.accounts.email_status')
+          %td
+            - if @account.user&.confirmed?
+              = t('admin.accounts.confirmed')
+            - else
+              = t('admin.accounts.confirming')
+              = 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= t('admin.accounts.login_status')
           %td
             - if @account.user&.disabled?
@@ -73,17 +79,17 @@
 
       %tr
         %th= t('admin.accounts.follows')
-        %td= @account.following_count
+        %td= number_to_human @account.following_count
       %tr
         %th= t('admin.accounts.followers')
-        %td= @account.followers_count
+        %td= number_to_human @account.followers_count
       %tr
         %th= t('admin.accounts.statuses')
-        %td= link_to @account.statuses_count, admin_account_statuses_path(@account.id)
+        %td= link_to number_to_human(@account.statuses_count), admin_account_statuses_path(@account.id)
       %tr
         %th= t('admin.accounts.media_attachments')
         %td
-          = link_to @account.media_attachments.count, admin_account_statuses_path(@account.id, { media: true })
+          = link_to number_to_human(@account.media_attachments.count), admin_account_statuses_path(@account.id, { media: true })
           = surround '(', ')' do
             = number_to_human_size @account.media_attachments.sum('file_file_size')
       %tr
@@ -120,11 +126,12 @@
       = link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:suspend, @account)
 
 - if !@account.local? && @account.hub_url.present?
-  %hr
+  %hr.spacer/
+
   %h3 OStatus
 
   .table-wrapper
-    %table.table
+    %table.table.inline-table
       %tbody
         %tr
           %th= t('admin.accounts.feed_url')
@@ -148,11 +155,12 @@
         = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' if can?(:unsubscribe, @account)
 
 - if !@account.local? && @account.inbox_url.present?
-  %hr
+  %hr.spacer/
+
   %h3 ActivityPub
 
   .table-wrapper
-    %table.table
+    %table.table.inline-table
       %tbody
         %tr
           %th= t('admin.accounts.inbox_url')
@@ -167,24 +175,15 @@
           %th= t('admin.accounts.followers_url')
           %td= link_to @account.followers_url, @account.followers_url
 
-%hr
-%h3= t('admin.accounts.moderation_notes')
+%hr.spacer/
+
+= render @moderation_notes
 
 = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
   = render 'shared/error_messages', object: @account_moderation_note
 
-  = f.input :content
+  = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
   = f.hidden_field :target_account_id
 
   .actions
-  = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
-
-.table-wrapper
-  %table.table
-    %thead
-      %tr
-        %th
-        %th= t('admin.account_moderation_notes.account')
-        %th= t('admin.account_moderation_notes.created_at')
-    %tbody
-      = render @moderation_notes
+    = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
diff --git a/app/views/admin/reports/_account.html.haml b/app/views/admin/reports/_account.html.haml
index 22b7a0861..9ac161c9c 100644
--- a/app/views/admin/reports/_account.html.haml
+++ b/app/views/admin/reports/_account.html.haml
@@ -15,5 +15,5 @@
           .account__avatar{ style: "background-image: url(#{account.avatar.url}); width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px" }
         %span.display-name
           %bdi
-            %strong.display-name__html.emojify= display_name(account)
+            %strong.display-name__html.emojify= display_name(account, custom_emojify: true)
           %span.display-name__account @#{account.acct}
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
index 137609539..5e174f312 100644
--- a/app/views/admin/reports/_status.html.haml
+++ b/app/views/admin/reports/_status.html.haml
@@ -3,26 +3,30 @@
     = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
   .batch-table__row__content
     .status__content><
-      - unless status.spoiler_text.blank?
+      - unless status.proper.spoiler_text.blank?
         %p><
-          %strong= Formatter.instance.format_spoiler(status)
+          %strong> Content warning: #{Formatter.instance.format_spoiler(status.proper)}
 
-      = Formatter.instance.format(status)
+      = Formatter.instance.format(status.proper, custom_emojify: true)
 
-    - unless status.media_attachments.empty?
-      - if status.media_attachments.first.video?
-        - video = status.media_attachments.first
-        = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 610, height: 343, inline: true
+    - unless status.proper.media_attachments.empty?
+      - if status.proper.media_attachments.first.video?
+        - video = status.proper.media_attachments.first
+        = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.proper.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 610, height: 343, inline: true
       - else
-        = react_component :media_gallery, height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
+        = react_component :media_gallery, height: 343, sensitive: status.proper.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
 
     .detailed-status__meta
       = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do
         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
       ·
-      = fa_visibility_icon(status)
-      = t("statuses.visibilities.#{status.visibility}")
-      - if status.sensitive?
+      - if status.reblog?
+        = fa_icon('retweet fw')
+        = t('statuses.boosted_from_html', acct_link: admin_account_inline_link_to(status.proper.account))
+      - else
+        = fa_visibility_icon(status)
+        = t("statuses.visibilities.#{status.visibility}")
+      - if status.proper.sensitive?
         ·
         = fa_icon('eye-slash fw')
         = t('stream_entries.sensitive_content')
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 38e47e6ca..ac2dec7ec 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -72,7 +72,11 @@
 .speech-bubble
   .speech-bubble__bubble= simple_format(@report.comment.presence || t('admin.reports.comment.none'))
   .speech-bubble__owner
-    = admin_account_link_to @report.account
+    - if @report.account.local?
+      = admin_account_link_to @report.account
+    - else
+      = @report.account.domain
+      %br/
     %time.formatted{ datetime: @report.created_at.iso8601 }
 
 - unless @report.statuses.empty?
diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml
index 9747a92cf..704ce1dbb 100644
--- a/app/views/admin/statuses/index.html.haml
+++ b/app/views/admin/statuses/index.html.haml
@@ -1,10 +1,7 @@
 - content_for :page_title do
   = t('admin.statuses.title')
-
-.back-link
-  = link_to admin_account_path(@account.id) do
-    %i.fa.fa-chevron-left.fa-fw
-    = t('admin.statuses.back_to_account')
+  \-
+  = "@#{@account.acct}"
 
 .filters
   .filter-subset
@@ -12,33 +9,26 @@
     %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'
+  .back-link{ style: 'flex: 1 1 auto; text-align: right' }
+    = 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]
 
-- if @statuses.empty?
-  .accounts-grid
-    = render 'accounts/nothing_here'
-- else
-  = 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-form-box
-      .batch-checkbox-all
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
         = check_box_tag :batch_checkbox_all, nil, false
-      = f.select :action, Form::StatusBatch::ACTION_TYPE.map{|action| [t("admin.statuses.batch.#{action}"), action]}
-      = f.submit t('admin.statuses.execute'), data: { confirm: t('admin.reports.are_you_sure') }, class: 'button'
-      .media-spoiler-toggle-buttons
-        .media-spoiler-show-button.button= t('admin.statuses.media.show')
-        .media-spoiler-hide-button.button= t('admin.statuses.media.hide')
-    - @statuses.each do |status|
-      .account-status{ data: { id: status.id } }
-        .batch-checkbox
-          = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
-        .activity-stream.activity-stream-headless
-          .entry= render 'stream_entries/simple_status', status: status
-        .account-status__actions
-          - unless status.media_attachments.empty?
-            = link_to admin_account_status_path(@account.id, status, current_params.merge(status: { sensitive: !status.sensitive })), method: :patch, class: 'icon-button nsfw-button', title: t("admin.reports.nsfw.#{!status.sensitive}") do
-              = fa_icon status.sensitive? ? 'eye' : 'eye-slash'
-          = link_to admin_account_status_path(@account.id, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') }, remote: true do
-            = fa_icon 'trash'
+      .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 }
 
 = paginate @statuses
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index 2b07c923b..1af3193ae 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -2,9 +2,12 @@
   = t('auth.login')
 
 = simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
-  = f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, required: true, autofocus: true, hint: t('simple_form.hints.sessions.otp')
+  %p.hint{ style: 'margin-bottom: 25px' }= t('simple_form.hints.sessions.otp')
+
+  = f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, required: true, autofocus: true
 
   .actions
     = f.button :button, t('auth.login'), type: :submit
 
-.form-footer= render 'auth/shared/links'
+  - if Setting.site_contact_email.present?
+    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
diff --git a/app/views/authorize_follows/_card.html.haml b/app/views/authorize_follows/_card.html.haml
index e81e292ba..9abcfd37e 100644
--- a/app/views/authorize_follows/_card.html.haml
+++ b/app/views/authorize_follows/_card.html.haml
@@ -6,7 +6,7 @@
     %span.display-name
       - account_url = local_assigns[:admin] ? admin_account_path(account.id) : TagManager.instance.url_for(account)
       = link_to account_url, class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
-        %strong.emojify= display_name(account)
+        %strong.emojify= display_name(account, custom_emojify: true)
         %span @#{account.acct}
 
   - if account.note?
diff --git a/app/views/follower_accounts/index.html.haml b/app/views/follower_accounts/index.html.haml
index a24e4ea20..65af81a5b 100644
--- a/app/views/follower_accounts/index.html.haml
+++ b/app/views/follower_accounts/index.html.haml
@@ -7,4 +7,7 @@
 
 = render 'accounts/header', account: @account
 
-= render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:account)
+- if @account.user_hides_network?
+  = render 'accounts/follow_grid_hidden'
+- else
+  = render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:account)
diff --git a/app/views/following_accounts/index.html.haml b/app/views/following_accounts/index.html.haml
index 67f6cfede..8fd95a0b4 100644
--- a/app/views/following_accounts/index.html.haml
+++ b/app/views/following_accounts/index.html.haml
@@ -7,4 +7,7 @@
 
 = render 'accounts/header', account: @account
 
-= render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:target_account)
+- if @account.user_hides_network?
+  = render 'accounts/follow_grid_hidden'
+- else
+  = render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:target_account)
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index 07441a77d..8bbd184bb 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -5,10 +5,10 @@
       %span.single-user-login
         = link_to t('auth.login'), new_user_session_path
         &mdash;
-      %span.domain= link_to site_hostname, about_path
+      %span.footer__domain= link_to site_hostname, about_path
     - else
-      %span.domain= link_to site_hostname, root_path
+      %span.footer__domain= link_to site_hostname, root_path
     %span.powered-by
-      != t('generic.powered_by', link: link_to('Mastodon', 'https://joinmastodon.org'))
+      != t('generic.powered_by', link: link_to('https://joinmastodon.org') { image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' })
 
 = render template: 'layouts/application'
diff --git a/app/views/remote_follow/new.html.haml b/app/views/remote_follow/new.html.haml
index fa48e5e62..fc5c4da20 100644
--- a/app/views/remote_follow/new.html.haml
+++ b/app/views/remote_follow/new.html.haml
@@ -7,7 +7,7 @@
   = simple_form_for @remote_follow, as: :remote_follow, url: account_remote_follow_path(@account) do |f|
     = render 'shared/error_messages', object: @remote_follow
 
-    = f.input :acct, placeholder: t('remote_follow.acct')
+    = f.input :acct, placeholder: t('remote_follow.acct'), input_html: { autocapitalize: 'none', autocorrect: 'off' }
 
     .actions
       = f.button :button, t('remote_follow.proceed'), type: :submit
diff --git a/app/views/remote_unfollows/_card.html.haml b/app/views/remote_unfollows/_card.html.haml
index e81e292ba..9abcfd37e 100644
--- a/app/views/remote_unfollows/_card.html.haml
+++ b/app/views/remote_unfollows/_card.html.haml
@@ -6,7 +6,7 @@
     %span.display-name
       - account_url = local_assigns[:admin] ? admin_account_path(account.id) : TagManager.instance.url_for(account)
       = link_to account_url, class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
-        %strong.emojify= display_name(account)
+        %strong.emojify= display_name(account, custom_emojify: true)
         %span @#{account.acct}
 
   - if account.note?
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index 89d768d3f..30cd26914 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -10,15 +10,15 @@
         %td
       %tr
         %th= t('exports.follows')
-        %td= @export.total_follows
+        %td= number_to_human @export.total_follows
         %td= table_link_to 'download', t('exports.csv'), settings_exports_follows_path(format: :csv)
       %tr
         %th= t('exports.blocks')
-        %td= @export.total_blocks
+        %td= number_to_human @export.total_blocks
         %td= table_link_to 'download', t('exports.csv'), settings_exports_blocks_path(format: :csv)
       %tr
         %th= t('exports.mutes')
-        %td= @export.total_mutes
+        %td= number_to_human @export.total_mutes
         %td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
 
 %p.muted-hint= t('exports.archive_takeout.hint_html')
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 102e4d200..4632034d7 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -26,6 +26,9 @@
   .fields-group
     = f.input :setting_noindex, as: :boolean, wrapper: :with_label
 
+  .fields-group
+    = f.input :setting_hide_network, as: :boolean, wrapper: :with_label
+
   %h4= t 'preferences.web'
 
   .fields-group
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 5f63466d9..d65a7f36f 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -20,6 +20,9 @@
     = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked')
 
   .fields-group
+    = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
+
+  .fields-group
     .input.with_block_label
       %label= t('simple_form.labels.defaults.fields')
       %span.hint= t('simple_form.hints.defaults.fields')
diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml
index ae26fc1ff..78f5ed4bc 100644
--- a/app/views/shared/_landing_strip.html.haml
+++ b/app/views/shared/_landing_strip.html.haml
@@ -2,7 +2,7 @@
   = image_tag asset_pack_path('logo.svg'), class: 'logo'
 
   %div
-    = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
+    = t('landing_strip_html', name: content_tag(:span, display_name(account, custom_emojify: true), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
 
     - if open_registrations?
       = t('landing_strip_signup_html', sign_up_path: new_user_registration_path)
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index afc66d148..c0f1e4f0f 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -4,7 +4,7 @@
       .avatar
         = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '', class: 'u-photo'
     %span.display-name
-      %strong.p-name.emojify= display_name(status.account)
+      %strong.p-name.emojify= display_name(status.account, custom_emojify: true)
       %span= acct(status.account)
 
   - if embedded_view?
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index cc2b6abe8..990e45094 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -10,7 +10,7 @@
         %div
           = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '', class: 'u-photo'
       %span.display-name
-        %strong.p-name.emojify= display_name(status.account)
+        %strong.p-name.emojify= display_name(status.account, custom_emojify: true)
         %span= acct(status.account)
 
   .status__content.p-name.emojify<
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index 9764bc74d..b87ca2177 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -28,7 +28,7 @@
         = fa_icon('retweet fw')
       %span
         = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do
-          %strong.emojify= display_name(status.account)
+          %strong.emojify= display_name(status.account, custom_emojify: true)
         = t('stream_entries.reblogged')
   - elsif pinned
     .pre-header
diff --git a/app/views/tags/_og.html.haml b/app/views/tags/_og.html.haml
index 853a499ae..a7c289bcb 100644
--- a/app/views/tags/_og.html.haml
+++ b/app/views/tags/_og.html.haml
@@ -2,5 +2,5 @@
 = opengraph 'og:url', tag_url(@tag)
 = opengraph 'og:type', 'website'
 = opengraph 'og:title', "##{@tag.name}"
-= opengraph 'og:description', t('about.about_hashtag_html', hashtag: @tag.name)
+= opengraph 'og:description', strip_tags(t('about.about_hashtag_html', hashtag: @tag.name))
 = opengraph 'twitter:card', 'summary'
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 000aa0c4d..2b46e58c7 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -2,6 +2,8 @@
   = "##{@tag.name}"
 
 - content_for :header_tags do
+  %link{ rel: 'alternate', type: 'application/rss+xml', href: tag_url(@tag, format: 'rss') }/
+
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
   = render 'og'
 
@@ -33,3 +35,5 @@
               %p= t 'about.about_mastodon_html'
 
               = render 'features'
+
+#modal-container
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index adffd1d3b..323a9f85b 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -3,6 +3,9 @@
 class ActivityPub::DeliveryWorker
   include Sidekiq::Worker
 
+  STOPLIGHT_FAILURE_THRESHOLD = 10
+  STOPLIGHT_COOLDOWN = 60
+
   sidekiq_options queue: 'push', retry: 16, dead: false
 
   HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
@@ -31,15 +34,21 @@ class ActivityPub::DeliveryWorker
   def perform_request
     light = Stoplight(@inbox_url) do
       build_request.perform do |response|
-        raise Mastodon::UnexpectedResponseError, response unless response_successful?(response)
+        raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response)
       end
     end
 
-    light.run
+    light.with_threshold(STOPLIGHT_FAILURE_THRESHOLD)
+         .with_cool_off_time(STOPLIGHT_COOLDOWN)
+         .run
   end
 
   def response_successful?(response)
-    response.code > 199 && response.code < 300
+    (200...300).cover?(response.code)
+  end
+
+  def response_error_unsalvageable?(response)
+    (400...500).cover?(response.code) && response.code != 429
   end
 
   def failure_tracker
diff --git a/app/workers/web/push_notification_worker.rb b/app/workers/web/push_notification_worker.rb
new file mode 100644
index 000000000..4a40e5c8b
--- /dev/null
+++ b/app/workers/web/push_notification_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Web::PushNotificationWorker
+  include Sidekiq::Worker
+
+  sidekiq_options backtrace: true
+
+  def perform(subscription_id, notification_id)
+    subscription = ::Web::PushSubscription.find(subscription_id)
+    notification = Notification.find(notification_id)
+
+    subscription.push(notification) unless notification.activity.nil?
+  rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription
+    subscription.destroy!
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/web_push_notification_worker.rb b/app/workers/web_push_notification_worker.rb
deleted file mode 100644
index eacea04c3..000000000
--- a/app/workers/web_push_notification_worker.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-class WebPushNotificationWorker
-  include Sidekiq::Worker
-
-  sidekiq_options backtrace: true
-
-  def perform(session_activation_id, notification_id)
-    session_activation = SessionActivation.find(session_activation_id)
-    notification       = Notification.find(notification_id)
-
-    return if session_activation.web_push_subscription.nil? || notification.activity.nil?
-
-    session_activation.web_push_subscription.push(notification)
-  rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription
-    # Subscription expiration is not currently implemented in any browser
-
-    session_activation.web_push_subscription.destroy!
-    session_activation.update!(web_push_subscription: nil)
-
-    true
-  rescue ActiveRecord::RecordNotFound
-    true
-  end
-end