about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb62
-rw-r--r--app/controllers/activitypub/inboxes_controller.rb40
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb2
-rw-r--r--app/controllers/admin/accounts_controller.rb2
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb34
-rw-r--r--app/controllers/admin/instances_controller.rb12
-rw-r--r--app/controllers/admin/settings_controller.rb15
-rw-r--r--app/controllers/admin/statuses_controller.rb4
-rw-r--r--app/controllers/api/base_controller.rb11
-rw-r--r--app/controllers/api/oembed_controller.rb8
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb5
-rw-r--r--app/controllers/api/v1/accounts_controller.rb11
-rw-r--r--app/controllers/api/v1/custom_emojis_controller.rb9
-rw-r--r--app/controllers/api/v1/follows_controller.rb6
-rw-r--r--app/controllers/api/v1/mutes_controller.rb31
-rw-r--r--app/controllers/api/v1/statuses/pins_controller.rb28
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/controllers/api/web/embeds_controller.rb17
-rw-r--r--app/controllers/application_controller.rb10
-rw-r--r--app/controllers/auth/confirmations_controller.rb6
-rw-r--r--app/controllers/auth/passwords_controller.rb15
-rw-r--r--app/controllers/authorize_follows_controller.rb2
-rw-r--r--app/controllers/concerns/account_controller_concern.rb8
-rw-r--r--app/controllers/concerns/signature_verification.rb25
-rw-r--r--app/controllers/follower_accounts_controller.rb25
-rw-r--r--app/controllers/following_accounts_controller.rb25
-rw-r--r--app/controllers/home_controller.rb35
-rw-r--r--app/controllers/intents_controller.rb18
-rw-r--r--app/controllers/media_proxy_controller.rb40
-rw-r--r--app/controllers/remote_follow_controller.rb2
-rw-r--r--app/controllers/settings/applications_controller.rb72
-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.rb30
-rw-r--r--app/controllers/statuses_controller.rb21
-rw-r--r--app/controllers/stream_entries_controller.rb12
-rw-r--r--app/controllers/tags_controller.rb2
-rw-r--r--app/helpers/application_helper.rb8
-rw-r--r--app/helpers/emoji_helper.rb24
-rw-r--r--app/helpers/jsonld_helper.rb52
-rw-r--r--app/helpers/routing_helper.rb10
-rw-r--r--app/helpers/settings_helper.rb5
-rw-r--r--app/helpers/stream_entries_helper.rb2
-rw-r--r--app/javascript/glitch/components/account/header.js20
-rw-r--r--app/javascript/glitch/components/local_settings/navigation/item/style.scss2
-rw-r--r--app/javascript/glitch/components/local_settings/navigation/style.scss2
-rw-r--r--app/javascript/glitch/components/local_settings/page/index.js19
-rw-r--r--app/javascript/glitch/components/local_settings/page/item/style.scss2
-rw-r--r--app/javascript/glitch/components/local_settings/page/style.scss2
-rw-r--r--app/javascript/glitch/components/local_settings/style.scss2
-rw-r--r--app/javascript/glitch/components/notification/follow.js84
-rw-r--r--app/javascript/glitch/components/status/action_bar.js58
-rw-r--r--app/javascript/glitch/components/status/container.js30
-rw-r--r--app/javascript/glitch/components/status/content.js20
-rw-r--r--app/javascript/glitch/components/status/gallery/item.js20
-rw-r--r--app/javascript/glitch/components/status/header.js166
-rw-r--r--app/javascript/glitch/components/status/index.js7
-rw-r--r--app/javascript/glitch/components/status/prepend.js8
-rw-r--r--app/javascript/glitch/locales/en.json1
-rw-r--r--app/javascript/glitch/reducers/local_settings.js1
-rw-r--r--app/javascript/glitch/util/bio_metadata.js16
-rw-r--r--app/javascript/images/logo.svg2
-rw-r--r--app/javascript/images/logo_alt.svg2
-rw-r--r--app/javascript/images/logo_full.svg2
-rw-r--r--app/javascript/images/mastodon_small.jpgbin25199 -> 0 bytes
-rw-r--r--app/javascript/images/preview.jpgbin0 -> 292252 bytes
-rw-r--r--app/javascript/mastodon/actions/accounts.js4
-rw-r--r--app/javascript/mastodon/actions/compose.js60
-rw-r--r--app/javascript/mastodon/actions/height_cache.js17
-rw-r--r--app/javascript/mastodon/actions/interactions.js78
-rw-r--r--app/javascript/mastodon/actions/mutes.js21
-rw-r--r--app/javascript/mastodon/actions/pin_statuses.js39
-rw-r--r--app/javascript/mastodon/actions/store.js3
-rw-r--r--app/javascript/mastodon/actions/streaming.js94
-rw-r--r--app/javascript/mastodon/components/account.js39
-rw-r--r--app/javascript/mastodon/components/autosuggest_emoji.js37
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js43
-rw-r--r--app/javascript/mastodon/components/avatar.js10
-rw-r--r--app/javascript/mastodon/components/avatar_overlay.js16
-rw-r--r--app/javascript/mastodon/components/column.js4
-rw-r--r--app/javascript/mastodon/components/display_name.js7
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js250
-rw-r--r--app/javascript/mastodon/components/icon_button.js17
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js125
-rw-r--r--app/javascript/mastodon/components/load_more.js2
-rw-r--r--app/javascript/mastodon/components/media_gallery.js47
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js215
-rw-r--r--app/javascript/mastodon/components/status.js147
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js33
-rw-r--r--app/javascript/mastodon/components/status_content.js6
-rw-r--r--app/javascript/mastodon/components/status_list.js157
-rw-r--r--app/javascript/mastodon/components/video_player.js12
-rw-r--r--app/javascript/mastodon/containers/account_container.js9
-rw-r--r--app/javascript/mastodon/containers/card_container.js18
-rw-r--r--app/javascript/mastodon/containers/compose_container.js39
-rw-r--r--app/javascript/mastodon/containers/intersection_observer_article_container.js17
-rw-r--r--app/javascript/mastodon/containers/mastodon.js82
-rw-r--r--app/javascript/mastodon/containers/media_gallery_container.js34
-rw-r--r--app/javascript/mastodon/containers/status_container.js14
-rw-r--r--app/javascript/mastodon/containers/video_container.js26
-rw-r--r--app/javascript/mastodon/emoji.js90
-rw-r--r--app/javascript/mastodon/emoji_map.json1
-rw-r--r--app/javascript/mastodon/emojione_light.js41
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/account/components/header.js19
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js16
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js13
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js18
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js50
-rw-r--r--app/javascript/mastodon/features/compose/components/autosuggest_account.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js83
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js334
-rw-r--r--app/javascript/mastodon/features/compose/components/navigation_bar.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_form.js2
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.js34
-rw-r--r--app/javascript/mastodon/features/compose/util/counter.js4
-rw-r--r--app/javascript/mastodon/features/compose/util/url_regex.js196
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js6
-rw-r--r--app/javascript/mastodon/features/favourites/index.js6
-rw-r--r--app/javascript/mastodon/features/follow_requests/components/account_authorize.js5
-rw-r--r--app/javascript/mastodon/features/followers/index.js16
-rw-r--r--app/javascript/mastodon/features/following/index.js16
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js30
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js35
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js17
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js6
-rw-r--r--app/javascript/mastodon/features/notifications/index.js106
-rw-r--r--app/javascript/mastodon/features/pinned_statuses/index.js59
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js50
-rw-r--r--app/javascript/mastodon/features/reblogs/index.js6
-rw-r--r--app/javascript/mastodon/features/report/components/status_check_box.js3
-rw-r--r--app/javascript/mastodon/features/standalone/compose/index.js20
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js25
-rw-r--r--app/javascript/mastodon/features/status/components/card.js19
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js9
-rw-r--r--app/javascript/mastodon/features/status/index.js28
-rw-r--r--app/javascript/mastodon/features/ui/components/actions_modal.js11
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js13
-rw-r--r--app/javascript/mastodon/features/ui/components/column_loading.js33
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js52
-rw-r--r--app/javascript/mastodon/features/ui/components/drawer_loading.js11
-rw-r--r--app/javascript/mastodon/features/ui/components/embed_modal.js84
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js28
-rw-r--r--app/javascript/mastodon/features/ui/components/mute_modal.js103
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js13
-rw-r--r--app/javascript/mastodon/features/ui/components/upload_area.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js22
-rw-r--r--app/javascript/mastodon/features/ui/containers/columns_area_container.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js34
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js32
-rw-r--r--app/javascript/mastodon/features/video/index.js304
-rw-r--r--app/javascript/mastodon/is_mobile.js13
-rw-r--r--app/javascript/mastodon/locales/ar.json23
-rw-r--r--app/javascript/mastodon/locales/bg.json21
-rw-r--r--app/javascript/mastodon/locales/ca.json21
-rw-r--r--app/javascript/mastodon/locales/de.json187
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json122
-rw-r--r--app/javascript/mastodon/locales/en.json23
-rw-r--r--app/javascript/mastodon/locales/eo.json21
-rw-r--r--app/javascript/mastodon/locales/es.json259
-rw-r--r--app/javascript/mastodon/locales/fa.json25
-rw-r--r--app/javascript/mastodon/locales/fi.json21
-rw-r--r--app/javascript/mastodon/locales/fr.json53
-rw-r--r--app/javascript/mastodon/locales/he.json21
-rw-r--r--app/javascript/mastodon/locales/hr.json75
-rw-r--r--app/javascript/mastodon/locales/hu.json21
-rw-r--r--app/javascript/mastodon/locales/id.json21
-rw-r--r--app/javascript/mastodon/locales/io.json21
-rw-r--r--app/javascript/mastodon/locales/it.json21
-rw-r--r--app/javascript/mastodon/locales/ja.json27
-rw-r--r--app/javascript/mastodon/locales/ko.json21
-rw-r--r--app/javascript/mastodon/locales/nl.json27
-rw-r--r--app/javascript/mastodon/locales/no.json21
-rw-r--r--app/javascript/mastodon/locales/oc.json73
-rw-r--r--app/javascript/mastodon/locales/pl.json69
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json249
-rw-r--r--app/javascript/mastodon/locales/pt.json21
-rw-r--r--app/javascript/mastodon/locales/ru.json59
-rw-r--r--app/javascript/mastodon/locales/th.json21
-rw-r--r--app/javascript/mastodon/locales/tr.json21
-rw-r--r--app/javascript/mastodon/locales/uk.json21
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json113
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json101
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json89
-rw-r--r--app/javascript/mastodon/reducers/accounts.js8
-rw-r--r--app/javascript/mastodon/reducers/accounts_counters.js2
-rw-r--r--app/javascript/mastodon/reducers/compose.js16
-rw-r--r--app/javascript/mastodon/reducers/custom_emojis.js16
-rw-r--r--app/javascript/mastodon/reducers/height_cache.js23
-rw-r--r--app/javascript/mastodon/reducers/index.js6
-rw-r--r--app/javascript/mastodon/reducers/mutes.js29
-rw-r--r--app/javascript/mastodon/reducers/reports.js2
-rw-r--r--app/javascript/mastodon/reducers/status_lists.js36
-rw-r--r--app/javascript/mastodon/reducers/statuses.js21
-rw-r--r--app/javascript/mastodon/scroll.js11
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js26
-rw-r--r--app/javascript/mastodon/web_push_subscription.js4
-rw-r--r--app/javascript/packs/application.js2
-rw-r--r--app/javascript/packs/frontends/mastodon.js16
-rw-r--r--app/javascript/packs/public.js72
-rw-r--r--app/javascript/packs/share.js24
-rw-r--r--app/javascript/styles/about.scss875
-rw-r--r--app/javascript/styles/accounts.scss347
-rw-r--r--app/javascript/styles/admin.scss75
-rw-r--r--app/javascript/styles/application.scss1
-rw-r--r--app/javascript/styles/basics.scss53
-rw-r--r--app/javascript/styles/boost.scss14
-rw-r--r--app/javascript/styles/compact_header.scss8
-rw-r--r--app/javascript/styles/components.scss985
-rw-r--r--app/javascript/styles/containers.scss76
-rw-r--r--app/javascript/styles/custom.scss1
-rw-r--r--app/javascript/styles/emoji_picker.scss199
-rw-r--r--app/javascript/styles/forms.scss86
-rw-r--r--app/javascript/styles/landing_strip.scss13
-rw-r--r--app/javascript/styles/rtl.scss131
-rw-r--r--app/javascript/styles/stream_entries.scss219
-rw-r--r--app/javascript/styles/tables.scss19
-rw-r--r--app/javascript/themes/default/theme.yml9
-rw-r--r--app/javascript/themes/spin/pack.js2
-rw-r--r--app/javascript/themes/spin/style.scss14
-rw-r--r--app/javascript/themes/spin/theme.yml2
-rw-r--r--app/lib/activitypub/activity.rb106
-rw-r--r--app/lib/activitypub/activity/accept.rb25
-rw-r--r--app/lib/activitypub/activity/announce.rb35
-rw-r--r--app/lib/activitypub/activity/block.rb12
-rw-r--r--app/lib/activitypub/activity/create.rb215
-rw-r--r--app/lib/activitypub/activity/delete.rb45
-rw-r--r--app/lib/activitypub/activity/follow.rb24
-rw-r--r--app/lib/activitypub/activity/like.rb12
-rw-r--r--app/lib/activitypub/activity/reject.rb25
-rw-r--r--app/lib/activitypub/activity/undo.rb71
-rw-r--r--app/lib/activitypub/activity/update.rb17
-rw-r--r--app/lib/activitypub/adapter.rb27
-rw-r--r--app/lib/activitypub/case_transform.rb24
-rw-r--r--app/lib/activitypub/linked_data_signature.rb56
-rw-r--r--app/lib/activitypub/tag_manager.rb40
-rw-r--r--app/lib/emoji.rb40
-rw-r--r--app/lib/formatter.rb68
-rw-r--r--app/lib/language_detector.rb40
-rw-r--r--app/lib/ostatus/activity/base.rb38
-rw-r--r--app/lib/ostatus/activity/creation.rb123
-rw-r--r--app/lib/ostatus/activity/deletion.rb4
-rw-r--r--app/lib/ostatus/activity/remote.rb6
-rw-r--r--app/lib/ostatus/activity/share.rb2
-rw-r--r--app/lib/ostatus/atom_serializer.rb117
-rw-r--r--app/lib/ostatus/tag_manager.rb73
-rw-r--r--app/lib/request.rb26
-rw-r--r--app/lib/status_finder.rb (renamed from app/lib/stream_entry_finder.rb)10
-rw-r--r--app/lib/tag_manager.rb64
-rw-r--r--app/lib/themes.rb23
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/mailers/notification_mailer.rb12
-rw-r--r--app/models/account.rb30
-rw-r--r--app/models/concerns/account_avatar.rb2
-rw-r--r--app/models/concerns/account_header.rb2
-rw-r--r--app/models/concerns/account_interactions.rb24
-rw-r--r--app/models/concerns/remotable.rb6
-rw-r--r--app/models/custom_emoji.rb44
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/models/import.rb1
-rw-r--r--app/models/instance_filter.rb28
-rw-r--r--app/models/media_attachment.rb16
-rw-r--r--app/models/mute.rb11
-rw-r--r--app/models/preview_card.rb31
-rw-r--r--app/models/remote_follow.rb2
-rw-r--r--app/models/remote_profile.rb12
-rw-r--r--app/models/report.rb2
-rw-r--r--app/models/session_activation.rb1
-rw-r--r--app/models/site_upload.rb44
-rw-r--r--app/models/status.rb53
-rw-r--r--app/models/status_pin.rb18
-rw-r--r--app/models/subscription.rb1
-rw-r--r--app/models/user.rb19
-rw-r--r--app/models/web/push_subscription.rb160
-rw-r--r--app/presenters/account_relationships_presenter.rb14
-rw-r--r--app/presenters/activitypub/collection_presenter.rb2
-rw-r--r--app/presenters/initial_state_presenter.rb3
-rw-r--r--app/presenters/instance_presenter.rb10
-rw-r--r--app/presenters/status_relationships_presenter.rb19
-rw-r--r--app/serializers/activitypub/accept_follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/activity_serializer.rb14
-rw-r--r--app/serializers/activitypub/actor_serializer.rb60
-rw-r--r--app/serializers/activitypub/block_serializer.rb6
-rw-r--r--app/serializers/activitypub/collection_serializer.rb25
-rw-r--r--app/serializers/activitypub/delete_serializer.rb29
-rw-r--r--app/serializers/activitypub/follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/like_serializer.rb6
-rw-r--r--app/serializers/activitypub/note_serializer.rb58
-rw-r--r--app/serializers/activitypub/reject_follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/undo_announce_serializer.rb19
-rw-r--r--app/serializers/activitypub/undo_block_serializer.rb6
-rw-r--r--app/serializers/activitypub/undo_follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/undo_like_serializer.rb6
-rw-r--r--app/serializers/activitypub/update_serializer.rb6
-rw-r--r--app/serializers/initial_state_serializer.rb21
-rw-r--r--app/serializers/oembed_serializer.rb18
-rw-r--r--app/serializers/rest/account_serializer.rb4
-rw-r--r--app/serializers/rest/application_serializer.rb4
-rw-r--r--app/serializers/rest/custom_emoji_serializer.rb11
-rw-r--r--app/serializers/rest/instance_serializer.rb22
-rw-r--r--app/serializers/rest/media_attachment_serializer.rb16
-rw-r--r--app/serializers/rest/mute_serializer.rb15
-rw-r--r--app/serializers/rest/notification_serializer.rb4
-rw-r--r--app/serializers/rest/relationship_serializer.rb4
-rw-r--r--app/serializers/rest/report_serializer.rb4
-rw-r--r--app/serializers/rest/status_serializer.rb33
-rw-r--r--app/serializers/web/notification_serializer.rb169
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb57
-rw-r--r--app/services/activitypub/fetch_remote_key_service.rb47
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb50
-rw-r--r--app/services/activitypub/process_account_service.rb168
-rw-r--r--app/services/activitypub/process_collection_service.rb48
-rw-r--r--app/services/authorize_follow_service.rb30
-rw-r--r--app/services/batched_remove_status_service.rb69
-rw-r--r--app/services/block_domain_service.rb11
-rw-r--r--app/services/block_service.rb19
-rw-r--r--app/services/bootstrap_timeline_service.rb34
-rw-r--r--app/services/concerns/author_extractor.rb6
-rw-r--r--app/services/favourite_service.rb28
-rw-r--r--app/services/fetch_atom_service.rb84
-rw-r--r--app/services/fetch_link_card_service.rb123
-rw-r--r--app/services/fetch_remote_account_service.rb18
-rw-r--r--app/services/fetch_remote_resource_service.rb33
-rw-r--r--app/services/fetch_remote_status_service.rb18
-rw-r--r--app/services/follow_service.rb20
-rw-r--r--app/services/mute_service.rb4
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/post_status_service.rb11
-rw-r--r--app/services/process_feed_service.rb2
-rw-r--r--app/services/process_interaction_service.rb20
-rw-r--r--app/services/process_mentions_service.rb28
-rw-r--r--app/services/reblog_service.rb28
-rw-r--r--app/services/reject_follow_service.rb19
-rw-r--r--app/services/remove_status_service.rb64
-rw-r--r--app/services/resolve_remote_account_service.rb56
-rw-r--r--app/services/subscribe_service.rb4
-rw-r--r--app/services/unblock_service.rb19
-rw-r--r--app/services/unfavourite_service.rb22
-rw-r--r--app/services/unfollow_service.rb45
-rw-r--r--app/services/unsubscribe_service.rb15
-rw-r--r--app/services/update_account_service.rb21
-rw-r--r--app/services/verify_salmon_service.rb2
-rw-r--r--app/validators/status_pin_validator.rb10
-rw-r--r--app/views/about/_og.html.haml10
-rw-r--r--app/views/about/_registration.html.haml21
-rw-r--r--app/views/about/more.html.haml15
-rw-r--r--app/views/about/show.html.haml22
-rw-r--r--app/views/accounts/_grid_card.html.haml11
-rw-r--r--app/views/accounts/_header.html.haml44
-rw-r--r--app/views/accounts/_og.html.haml17
-rw-r--r--app/views/accounts/show.html.haml13
-rw-r--r--app/views/admin/accounts/_account.html.haml3
-rw-r--r--app/views/admin/accounts/_card.html.haml31
-rw-r--r--app/views/admin/accounts/index.html.haml22
-rw-r--r--app/views/admin/accounts/show.html.haml192
-rw-r--r--app/views/admin/custom_emojis/_custom_emoji.html.haml7
-rw-r--r--app/views/admin/custom_emojis/index.html.haml14
-rw-r--r--app/views/admin/custom_emojis/new.html.haml12
-rw-r--r--app/views/admin/domain_blocks/index.html.haml19
-rw-r--r--app/views/admin/instances/_instance.html.haml2
-rw-r--r--app/views/admin/instances/index.html.haml25
-rw-r--r--app/views/admin/reports/index.html.haml25
-rw-r--r--app/views/admin/settings/edit.html.haml10
-rw-r--r--app/views/admin/subscriptions/index.html.haml21
-rw-r--r--app/views/auth/registrations/_sessions.html.haml47
-rw-r--r--app/views/auth/registrations/edit.html.haml2
-rw-r--r--app/views/auth/registrations/new.html.haml4
-rw-r--r--app/views/auth/sessions/two_factor.html.haml4
-rw-r--r--app/views/authorize_follows/show.html.haml9
-rw-r--r--app/views/errors/500.html.haml5
-rw-r--r--app/views/home/index.html.haml6
-rwxr-xr-xapp/views/layouts/application.html.haml16
-rw-r--r--app/views/layouts/embedded.html.haml1
-rw-r--r--app/views/layouts/error.html.haml32
-rw-r--r--app/views/layouts/modal.html.haml16
-rw-r--r--app/views/oauth/authorizations/show.html.haml3
-rw-r--r--app/views/oauth/authorized_applications/index.html.haml39
-rw-r--r--app/views/settings/applications/_fields.html.haml21
-rw-r--r--app/views/settings/applications/index.html.haml20
-rw-r--r--app/views/settings/applications/new.html.haml8
-rw-r--r--app/views/settings/applications/show.html.haml31
-rw-r--r--app/views/settings/exports/show.html.haml37
-rw-r--r--app/views/settings/follower_domains/show.html.haml27
-rw-r--r--app/views/settings/preferences/show.html.haml4
-rw-r--r--app/views/settings/profiles/show.html.haml4
-rw-r--r--app/views/shared/_landing_strip.html.haml9
-rw-r--r--app/views/shares/show.html.haml5
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml26
-rw-r--r--app/views/stream_entries/_og_description.html.haml4
-rw-r--r--app/views/stream_entries/_og_image.html.haml25
-rw-r--r--app/views/stream_entries/_simple_status.html.haml23
-rw-r--r--app/views/stream_entries/_status.html.haml7
-rw-r--r--app/views/stream_entries/embed.html.haml5
-rw-r--r--app/views/stream_entries/show.html.haml11
-rw-r--r--app/views/tags/show.html.haml1
-rw-r--r--app/views/user_mailer/confirmation_instructions.es.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.es.text.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.oc.html.erb6
-rw-r--r--app/views/user_mailer/confirmation_instructions.oc.text.erb6
-rw-r--r--app/views/user_mailer/confirmation_instructions.pt-BR.html.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.pt-BR.text.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.zh-cn.html.erb2
-rw-r--r--app/views/user_mailer/confirmation_instructions.zh-cn.text.erb2
-rw-r--r--app/views/user_mailer/password_change.es.html.erb3
-rw-r--r--app/views/user_mailer/password_change.es.text.erb3
-rw-r--r--app/views/user_mailer/password_change.oc.html.erb4
-rw-r--r--app/views/user_mailer/password_change.oc.text.erb4
-rw-r--r--app/views/user_mailer/password_change.pt-BR.html.erb3
-rw-r--r--app/views/user_mailer/password_change.pt-BR.text.erb3
-rw-r--r--app/views/user_mailer/reset_password_instructions.es.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.es.text.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.oc.html.erb6
-rw-r--r--app/views/user_mailer/reset_password_instructions.oc.text.erb6
-rw-r--r--app/views/user_mailer/reset_password_instructions.pt-BR.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.pt-BR.text.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.zh-cn.html.erb2
-rw-r--r--app/views/user_mailer/reset_password_instructions.zh-cn.text.erb2
-rw-r--r--app/workers/activitypub/delivery_worker.rb37
-rw-r--r--app/workers/activitypub/distribution_worker.rb42
-rw-r--r--app/workers/activitypub/post_upgrade_worker.rb15
-rw-r--r--app/workers/activitypub/processing_worker.rb11
-rw-r--r--app/workers/activitypub/raw_distribution_worker.rb23
-rw-r--r--app/workers/activitypub/reply_distribution_worker.rb42
-rw-r--r--app/workers/activitypub/update_distribution_worker.rb31
-rw-r--r--app/workers/authorize_follow_worker.rb14
-rw-r--r--app/workers/bootstrap_timeline_worker.rb9
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb28
-rw-r--r--app/workers/pubsubhubbub/raw_distribution_worker.rb22
-rw-r--r--app/workers/pubsubhubbub/subscribe_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/unsubscribe_worker.rb15
-rw-r--r--app/workers/refollow_worker.rb24
-rw-r--r--app/workers/resolve_remote_account_worker.rb11
-rw-r--r--app/workers/scheduler/feed_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb12
-rw-r--r--app/workers/scheduler/media_cleanup_scheduler.rb1
-rw-r--r--app/workers/scheduler/subscriptions_cleanup_scheduler.rb11
-rw-r--r--app/workers/scheduler/subscriptions_scheduler.rb2
445 files changed, 11270 insertions, 4402 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index c270eb000..26ab6636b 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -7,24 +7,78 @@ class AccountsController < ApplicationController
   def show
     respond_to do |format|
       format.html do
-        @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
-        @statuses = cache_collection(@statuses, Status)
+        @pinned_statuses = []
+
+        if current_account && @account.blocking?(current_account)
+          @statuses = []
+          return
+        end
+
+        @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
+        @statuses        = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id])
+        @statuses        = cache_collection(@statuses, Status)
+        @next_url        = next_url unless @statuses.empty?
       end
 
       format.atom do
         @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
-        render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a))
+        render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? }))
       end
 
       format.json do
-        render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
+        render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
 
   private
 
+  def show_pinned_statuses?
+    [replies_requested?, media_requested?, params[:max_id].present?, params[:since_id].present?].none?
+  end
+
+  def filtered_statuses
+    default_statuses.tap do |statuses|
+      statuses.merge!(only_media_scope) if media_requested?
+      statuses.merge!(no_replies_scope) unless replies_requested?
+    end
+  end
+
+  def default_statuses
+    @account.statuses.where(visibility: [:public, :unlisted])
+  end
+
+  def only_media_scope
+    Status.where(id: account_media_status_ids)
+  end
+
+  def account_media_status_ids
+    @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
+  end
+
+  def no_replies_scope
+    Status.without_replies
+  end
+
   def set_account
     @account = Account.find_local!(params[:username])
   end
+
+  def next_url
+    if media_requested?
+      short_account_media_url(@account, max_id: @statuses.last.id)
+    elsif replies_requested?
+      short_account_with_replies_url(@account, max_id: @statuses.last.id)
+    else
+      short_account_url(@account, max_id: @statuses.last.id)
+    end
+  end
+
+  def media_requested?
+    request.path.ends_with?('/media')
+  end
+
+  def replies_requested?
+    request.path.ends_with?('/with_replies')
+  end
 end
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
new file mode 100644
index 000000000..b37910b36
--- /dev/null
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class ActivityPub::InboxesController < Api::BaseController
+  include SignatureVerification
+
+  before_action :set_account
+
+  def create
+    if signed_request_account
+      upgrade_account
+      process_payload
+      head 201
+    else
+      head 202
+    end
+  end
+
+  private
+
+  def set_account
+    @account = Account.find_local!(params[:account_username]) if params[:account_username]
+  end
+
+  def body
+    @body ||= request.body.read
+  end
+
+  def upgrade_account
+    if signed_request_account.ostatus?
+      signed_request_account.update(last_webfingered_at: nil)
+      ResolveRemoteAccountWorker.perform_async(signed_request_account.acct)
+    end
+
+    Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed?
+  end
+
+  def process_payload
+    ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
+  end
+end
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index 30b91f370..9f97ff622 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController
     @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
     @statuses = cache_collection(@statuses, Status)
 
-    render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+    render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
   end
 
   private
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 7bceee2cd..54c659e1b 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -17,7 +17,7 @@ module Admin
     end
 
     def unsubscribe
-      UnsubscribeService.new.call(@account)
+      Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id)
       redirect_to admin_account_path(@account.id)
     end
 
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
new file mode 100644
index 000000000..d70514d9a
--- /dev/null
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Admin
+  class CustomEmojisController < BaseController
+    def index
+      @custom_emojis = CustomEmoji.local
+    end
+
+    def new
+      @custom_emoji = CustomEmoji.new
+    end
+
+    def create
+      @custom_emoji = CustomEmoji.new(resource_params)
+
+      if @custom_emoji.save
+        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
+      else
+        render :new
+      end
+    end
+
+    def destroy
+      CustomEmoji.find(params[:id]).destroy
+      redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
+    end
+
+    private
+
+    def resource_params
+      params.require(:custom_emoji).permit(:shortcode, :image)
+    end
+  end
+end
diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb
index 3296e08db..22f02e5d0 100644
--- a/app/controllers/admin/instances_controller.rb
+++ b/app/controllers/admin/instances_controller.rb
@@ -14,8 +14,12 @@ module Admin
 
     private
 
+    def filtered_instances
+      InstanceFilter.new(filter_params).results
+    end
+
     def paginated_instances
-      Account.remote.by_domain_accounts.page(params[:page])
+      filtered_instances.page(params[:page])
     end
 
     helper_method :paginated_instances
@@ -27,5 +31,11 @@ module Admin
     def subscribeable_accounts
       Account.with_followers.remote.where(domain: params[:by_domain])
     end
+
+    def filter_params
+      params.permit(
+        :domain_name
+      )
+    end
   end
 end
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index 5985d6282..a2f86b8a9 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -13,6 +13,8 @@ module Admin
       closed_registrations_message
       open_deletion
       timeline_preview
+      bootstrap_timeline_accounts
+      thumbnail
     ).freeze
 
     BOOLEAN_SETTINGS = %w(
@@ -21,14 +23,23 @@ module Admin
       timeline_preview
     ).freeze
 
+    UPLOAD_SETTINGS = %w(
+      thumbnail
+    ).freeze
+
     def edit
       @admin_settings = Form::AdminSettings.new
     end
 
     def update
       settings_params.each do |key, value|
-        setting = Setting.where(var: key).first_or_initialize(var: key)
-        setting.update(value: value_for_update(key, value))
+        if UPLOAD_SETTINGS.include?(key)
+          upload = SiteUpload.where(var: key).first_or_initialize(var: key)
+          upload.update(file: value)
+        else
+          setting = Setting.where(var: key).first_or_initialize(var: key)
+          setting.update(value: value_for_update(key, value))
+        end
       end
 
       flash[:notice] = I18n.t('generic.changes_saved_msg')
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index 50712f0dd..b05000b16 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -9,7 +9,7 @@ module Admin
     before_action :set_account
     before_action :set_status, only: [:update, :destroy]
 
-    PAR_PAGE = 20
+    PER_PAGE = 20
 
     def index
       @statuses = @account.statuses
@@ -17,7 +17,7 @@ module Admin
         account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
         @statuses.merge!(Status.where(id: account_media_status_ids))
       end
-      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE)
+      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
 
       @form = Form::StatusBatch.new
     end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 105a2859d..7cfe8fe71 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController
     links = []
     links << [next_path, [%w(rel next)]] if next_path
     links << [prev_path, [%w(rel prev)]] if prev_path
-    response.headers['Link'] = LinkHeader.new(links)
+    response.headers['Link'] = LinkHeader.new(links) unless links.empty?
   end
 
   def limit_param(default_limit)
@@ -62,10 +62,11 @@ class Api::BaseController < ApplicationController
   end
 
   def require_user!
-    current_resource_owner
-    set_user_activity
-  rescue ActiveRecord::RecordNotFound
-    render json: { error: 'This method requires an authenticated user' }, status: 422
+    if current_user
+      set_user_activity
+    else
+      render json: { error: 'This method requires an authenticated user' }, status: 422
+    end
   end
 
   def render_empty
diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb
index f8c87dd16..37a163cd3 100644
--- a/app/controllers/api/oembed_controller.rb
+++ b/app/controllers/api/oembed_controller.rb
@@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController
   respond_to :json
 
   def show
-    @stream_entry = find_stream_entry.stream_entry
-    render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
+    @status = status_finder.status
+    render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
   end
 
   private
 
-  def find_stream_entry
-    StreamEntryFinder.new(params[:url])
+  def status_finder
+    StatusFinder.new(params[:url])
   end
 
   def maxwidth_or_default
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index 073808532..da534d960 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Api::V1::Accounts::CredentialsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read }, except: [:update]
   before_action -> { doorkeeper_authorize! :write }, only: [:update]
   before_action :require_user!
 
@@ -10,8 +11,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   end
 
   def update
-    current_account.update!(account_params)
     @account = current_account
+    UpdateAccountService.new.call(@account, account_params, raise_error: true)
+    ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
     render json: @account, serializer: REST::CredentialAccountSerializer
   end
 
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index d9ae5c089..095f6937b 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   def account_statuses
     default_statuses.tap do |statuses|
       statuses.merge!(only_media_scope) if params[:only_media]
+      statuses.merge!(pinned_scope) if params[:pinned]
       statuses.merge!(no_replies_scope) if params[:exclude_replies]
     end
   end
@@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
   end
 
+  def pinned_scope
+    @account.pinned_statuses
+  end
+
   def no_replies_scope
     Status.without_replies
   end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index f621aa245..4676f60de 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -14,7 +14,10 @@ class Api::V1::AccountsController < Api::BaseController
 
   def follow
     FollowService.new.call(current_user.account, @account.acct)
-    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
+
+    options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } }
+
+    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
   end
 
   def block
@@ -23,7 +26,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def mute
-    MuteService.new.call(current_user.account, @account)
+    MuteService.new.call(current_user.account, @account, notifications: params[:notifications])
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
@@ -48,7 +51,7 @@ class Api::V1::AccountsController < Api::BaseController
     @account = Account.find(params[:id])
   end
 
-  def relationships
-    AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
+  def relationships(options = {})
+    AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options)
   end
 end
diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb
new file mode 100644
index 000000000..4dd77fb55
--- /dev/null
+++ b/app/controllers/api/v1/custom_emojis_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Api::V1::CustomEmojisController < Api::BaseController
+  respond_to :json
+
+  def index
+    render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer
+  end
+end
diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb
index e01ae5c01..5a2b2f32f 100644
--- a/app/controllers/api/v1/follows_controller.rb
+++ b/app/controllers/api/v1/follows_controller.rb
@@ -10,6 +10,12 @@ class Api::V1::FollowsController < Api::BaseController
     raise ActiveRecord::RecordNotFound if follow_params[:uri].blank?
 
     @account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
+
+    if @account.nil?
+      username, domain = target_uri.split('@')
+      @account         = Account.find_remote!(username, domain)
+    end
+
     render json: @account, serializer: REST::AccountSerializer
   end
 
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index 0c43cb943..92ad251ef 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -8,10 +8,15 @@ class Api::V1::MutesController < Api::BaseController
   respond_to :json
 
   def index
-    @accounts = load_accounts
+    @data = @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
   end
 
+  def details
+    @data = @mutes = load_mutes
+    render json: @mutes, each_serializer: REST::MuteSerializer
+  end 
+
   private
 
   def load_accounts
@@ -22,6 +27,10 @@ class Api::V1::MutesController < Api::BaseController
     Account.includes(:muted_by).references(:muted_by)
   end
 
+  def load_mutes
+    paginated_mutes.includes(:account, :target_account).to_a
+  end
+
   def paginated_mutes
     Mute.where(account: current_account).paginate_by_max_id(
       limit_param(DEFAULT_ACCOUNTS_LIMIT),
@@ -36,26 +45,34 @@ class Api::V1::MutesController < Api::BaseController
 
   def next_path
     if records_continue?
-      api_v1_mutes_url pagination_params(max_id: pagination_max_id)
+      url_for pagination_params(max_id: pagination_max_id)
     end
   end
 
   def prev_path
-    unless @accounts.empty?
-      api_v1_mutes_url pagination_params(since_id: pagination_since_id)
+    unless@data.empty?
+      url_for pagination_params(since_id: pagination_since_id)
     end
   end
 
   def pagination_max_id
-    @accounts.last.muted_by_ids.last
+    if params[:action] == "details"
+      @mutes.last.id
+    else
+      @accounts.last.muted_by_ids.last
+    end
   end
 
   def pagination_since_id
-    @accounts.first.muted_by_ids.first
+    if params[:action] == "details"
+      @mutes.first.id
+    else
+      @accounts.first.muted_by_ids.first
+    end
   end
 
   def records_continue?
-    @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+    @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
   end
 
   def pagination_params(core_params)
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
new file mode 100644
index 000000000..3de1009b8
--- /dev/null
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::PinsController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write }
+  before_action :require_user!
+  before_action :set_status
+
+  respond_to :json
+
+  def create
+    StatusPin.create!(account: current_account, status: @status)
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  def destroy
+    pin = StatusPin.find_by(account: current_account, status: @status)
+    pin&.destroy!
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  private
+
+  def set_status
+    @status = Status.find(params[:status_id])
+  end
+end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 9c7124d0f..544a4ce21 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController
   end
 
   def card
-    @card = PreviewCard.find_by(status: @status)
+    @card = @status.preview_cards.first
 
     if @card.nil?
       render_empty
diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb
new file mode 100644
index 000000000..2ed516161
--- /dev/null
+++ b/app/controllers/api/web/embeds_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::Web::EmbedsController < Api::BaseController
+  respond_to :json
+
+  before_action :require_user!
+
+  def create
+    status = StatusFinder.new(params[:url]).status
+    render json: status, serializer: OEmbedSerializer, width: 400
+  rescue ActiveRecord::RecordNotFound
+    oembed = OEmbed::Providers.get(params[:url])
+    render json: Oj.dump(oembed.fields)
+  rescue OEmbed::NotFound
+    render json: {}, status: :not_found
+  end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index b3c2db02b..d5eca6ffb 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
 
   helper_method :current_account
   helper_method :current_session
+  helper_method :current_theme
   helper_method :single_user_mode?
 
   rescue_from ActionController::RoutingError, with: :not_found
@@ -43,6 +44,10 @@ class ApplicationController < ActionController::Base
     forbidden if current_user.account.suspended?
   end
 
+  def after_sign_out_path_for(_resource_or_scope)
+    new_user_session_path
+  end
+
   protected
 
   def forbidden
@@ -73,6 +78,11 @@ class ApplicationController < ActionController::Base
     @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
   end
 
+  def current_theme
+    return Setting.default_settings['theme'] unless Themes.instance.names.include? current_user&.setting_theme
+    current_user.setting_theme
+  end
+
   def cache_collection(raw, klass)
     return raw unless klass.respond_to?(:with_includes)
 
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 2fdb281f4..d5e8e58ed 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -2,4 +2,10 @@
 
 class Auth::ConfirmationsController < Devise::ConfirmationsController
   layout 'auth'
+
+  def show
+    super do |user|
+      BootstrapTimelineWorker.perform_async(user.account_id) if user.errors.empty?
+    end
+  end
 end
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index 54ee1c39c..171b997dc 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -1,5 +1,20 @@
 # frozen_string_literal: true
 
 class Auth::PasswordsController < Devise::PasswordsController
+  before_action :check_validity_of_reset_password_token, only: :edit
+
   layout 'auth'
+
+  private
+
+  def check_validity_of_reset_password_token
+    unless reset_password_token_is_valid?
+      flash[:error] = I18n.t('auth.invalid_reset_password_token')
+      redirect_to new_password_path(resource_name)
+    end
+  end
+
+  def reset_password_token_is_valid?
+    resource_class.with_reset_password_token(params[:reset_password_token]).present?
+  end
 end
diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb
index dccd1c209..78b564183 100644
--- a/app/controllers/authorize_follows_controller.rb
+++ b/app/controllers/authorize_follows_controller.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class AuthorizeFollowsController < ApplicationController
-  layout 'public'
+  layout 'modal'
 
   before_action :authenticate_user!
 
diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb
index d36fc8c93..5b9981aa2 100644
--- a/app/controllers/concerns/account_controller_concern.rb
+++ b/app/controllers/concerns/account_controller_concern.rb
@@ -23,6 +23,7 @@ module AccountControllerConcern
       [
         webfinger_account_link,
         atom_account_url_link,
+        actor_url_link,
       ]
     )
   end
@@ -41,6 +42,13 @@ module AccountControllerConcern
     ]
   end
 
+  def actor_url_link
+    [
+      ActivityPub::TagManager.instance.uri_for(@account),
+      [%w(rel alternate), %w(type application/activity+json)],
+    ]
+  end
+
   def webfinger_account_url
     webfinger_url(resource: @account.to_webfinger_s)
   end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index abe845d93..4211283ed 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -31,7 +31,7 @@ module SignatureVerification
       return
     end
 
-    account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
+    account = account_from_key_id(signature_params['keyId'])
 
     if account.nil?
       @signed_request_account = nil
@@ -49,6 +49,10 @@ module SignatureVerification
     end
   end
 
+  def request_body
+    @request_body ||= request.raw_post
+  end
+
   private
 
   def build_signed_string(signed_headers)
@@ -57,6 +61,8 @@ module SignatureVerification
     signed_headers.split(' ').map do |signed_header|
       if signed_header == Request::REQUEST_TARGET
         "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
+      elsif signed_header == 'digest'
+        "digest: #{body_digest}"
       else
         "#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
       end
@@ -73,6 +79,10 @@ module SignatureVerification
     (Time.now.utc - time_sent).abs <= 30
   end
 
+  def body_digest
+    "SHA-256=#{Digest::SHA256.base64digest(request_body)}"
+  end
+
   def to_header_name(name)
     name.split(/-/).map(&:capitalize).join('-')
   end
@@ -81,7 +91,16 @@ module SignatureVerification
     signature_params['keyId'].blank? ||
       signature_params['signature'].blank? ||
       signature_params['algorithm'].blank? ||
-      signature_params['algorithm'] != 'rsa-sha256' ||
-      !signature_params['keyId'].start_with?('acct:')
+      signature_params['algorithm'] != 'rsa-sha256'
+  end
+
+  def account_from_key_id(key_id)
+    if key_id.start_with?('acct:')
+      ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
+    elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
+      account   = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
+      account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id)
+      account
+    end
   end
 end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 5edb4d67c..8eb4d2822 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -10,19 +10,36 @@ class FollowerAccountsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
 
   private
 
+  def page_url(page)
+    account_followers_url(@account, page: page) unless page.nil?
+  end
+
   def collection_presenter
-    ActivityPub::CollectionPresenter.new(
-      id: account_followers_url(@account),
+    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) }
+      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
+    else
+      ActivityPub::CollectionPresenter.new(
+        id: account_followers_url(@account),
+        type: :ordered,
+        size: @account.followers_count,
+        first: page
+      )
+    end
   end
 end
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 7cafe5fda..1ca6f0fe7 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -10,19 +10,36 @@ class FollowingAccountsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
 
   private
 
+  def page_url(page)
+    account_following_index_url(@account, page: page) unless page.nil?
+  end
+
   def collection_presenter
-    ActivityPub::CollectionPresenter.new(
-      id: account_following_index_url(@account),
+    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) }
+      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
+    else
+      ActivityPub::CollectionPresenter.new(
+        id: account_following_index_url(@account),
+        type: :ordered,
+        size: @account.following_count,
+        first: page
+      )
+    end
   end
 end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index fbfb5473e..ad7f09f34 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -12,7 +12,30 @@ class HomeController < ApplicationController
   private
 
   def authenticate_user!
-    redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in?
+    return if user_signed_in?
+
+    matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
+
+    if matches
+      case matches[1]
+      when 'statuses'
+        status = Status.find_by(id: matches[2])
+
+        if status && (status.public_visibility? || status.unlisted_visibility?)
+          redirect_to(ActivityPub::TagManager.instance.url_for(status))
+          return
+        end
+      when 'accounts'
+        account = Account.find_by(id: matches[2])
+
+        if account
+          redirect_to(ActivityPub::TagManager.instance.url_for(account))
+          return
+        end
+      end
+    end
+
+    redirect_to(default_redirect_path)
   end
 
   def set_initial_state_json
@@ -29,4 +52,14 @@ class HomeController < ApplicationController
       admin: Account.find_local(Setting.site_contact_username),
     }
   end
+
+  def default_redirect_path
+    if request.path.start_with?('/web')
+      new_user_session_path
+    elsif single_user_mode?
+      short_account_path(Account.first)
+    else
+      about_path
+    end
+  end
 end
diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb
new file mode 100644
index 000000000..504befd1f
--- /dev/null
+++ b/app/controllers/intents_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class IntentsController < ApplicationController
+  def show
+    uri = Addressable::URI.parse(params[:uri])
+
+    if uri.scheme == 'web+mastodon'
+      case uri.host
+      when 'follow'
+        return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
+      when 'share'
+        return redirect_to share_path(text: uri.query_values['text'])
+      end
+    end
+
+    not_found
+  end
+end
diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb
new file mode 100644
index 000000000..155670837
--- /dev/null
+++ b/app/controllers/media_proxy_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class MediaProxyController < ApplicationController
+  include RoutingHelper
+
+  def show
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @media_attachment = MediaAttachment.remote.find(params[:id])
+        redownload! if @media_attachment.needs_redownload? && !reject_media?
+      end
+    end
+
+    redirect_to full_asset_url(@media_attachment.file.url(version))
+  end
+
+  private
+
+  def redownload!
+    @media_attachment.file_remote_url = @media_attachment.remote_url
+    @media_attachment.created_at      = Time.now.utc
+    @media_attachment.save!
+  end
+
+  def version
+    if request.path.ends_with?('/small')
+      :small
+    else
+      :original
+    end
+  end
+
+  def lock_options
+    { redis: Redis.current, key: "media_download:#{params[:id]}" }
+  end
+
+  def reject_media?
+    DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media?
+  end
+end
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index 2988231b1..48b026aa5 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class RemoteFollowController < ApplicationController
-  layout 'public'
+  layout 'modal'
 
   before_action :set_account
   before_action :gone, if: :suspended_account?
diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb
new file mode 100644
index 000000000..8fc9a0fa9
--- /dev/null
+++ b/app/controllers/settings/applications_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+class Settings::ApplicationsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_application, only: [:show, :update, :destroy, :regenerate]
+  before_action :prepare_scopes, only: [:create, :update]
+
+  def index
+    @applications = current_user.applications.page(params[:page])
+  end
+
+  def new
+    @application = Doorkeeper::Application.new(
+      redirect_uri: Doorkeeper.configuration.native_redirect_uri,
+      scopes: 'read write follow'
+    )
+  end
+
+  def show; end
+
+  def create
+    @application = current_user.applications.build(application_params)
+
+    if @application.save
+      redirect_to settings_applications_path, notice: I18n.t('applications.created')
+    else
+      render :new
+    end
+  end
+
+  def update
+    if @application.update(application_params)
+      redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render :show
+    end
+  end
+
+  def destroy
+    @application.destroy
+    redirect_to settings_applications_path, notice: I18n.t('applications.destroyed')
+  end
+
+  def regenerate
+    @access_token = current_user.token_for_app(@application)
+    @access_token.destroy
+
+    redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated')
+  end
+
+  private
+
+  def set_application
+    @application = current_user.applications.find(params[:id])
+  end
+
+  def application_params
+    params.require(:doorkeeper_application).permit(
+      :name,
+      :redirect_uri,
+      :scopes,
+      :website
+    )
+  end
+
+  def prepare_scopes
+    scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
+    params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array
+  end
+end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index f107f2b16..207c7b324 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -41,6 +41,7 @@ class Settings::PreferencesController < ApplicationController
       :setting_auto_play_gif,
       :setting_system_font_ui,
       :setting_noindex,
+      :setting_theme,
       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 0367e3593..28f78a4fb 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -14,7 +14,8 @@ class Settings::ProfilesController < ApplicationController
   def show; end
 
   def update
-    if @account.update(account_params)
+    if UpdateAccountService.new.call(@account, account_params)
+      ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
       redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
     else
       render :show
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
new file mode 100644
index 000000000..994742c3d
--- /dev/null
+++ b/app/controllers/shares_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class SharesController < ApplicationController
+  layout 'modal'
+
+  before_action :authenticate_user!
+  before_action :set_body_classes
+
+  def show
+    serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
+    @initial_state_json   = serializable_resource.to_json
+  end
+
+  private
+
+  def initial_state_params
+    {
+      settings: Web::Setting.find_by(user: current_user)&.data || {},
+      push_subscription: current_account.user.web_push_subscription(current_session),
+      current_account: current_account,
+      token: current_session.token,
+      admin: Account.find_local(Setting.site_contact_username),
+      text: params[:text],
+    }
+  end
+
+  def set_body_classes
+    @body_classes = 'compose-standalone'
+  end
+end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 8e0ce0ec3..65206ea96 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -9,6 +9,7 @@ class StatusesController < ApplicationController
   before_action :set_status
   before_action :set_link_headers
   before_action :check_account_suspension
+  before_action :redirect_to_original, only: [:show]
 
   def show
     respond_to do |format|
@@ -20,13 +21,18 @@ class StatusesController < ApplicationController
       end
 
       format.json do
-        render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
+        render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
 
   def activity
-    render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
+    render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+  end
+
+  def embed
+    response.headers['X-Frame-Options'] = 'ALLOWALL'
+    render 'stream_entries/embed', layout: 'embedded'
   end
 
   private
@@ -36,7 +42,12 @@ class StatusesController < ApplicationController
   end
 
   def set_link_headers
-    response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
+    response.headers['Link'] = LinkHeader.new(
+      [
+        [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
+        [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]],
+      ]
+    )
   end
 
   def set_status
@@ -53,4 +64,8 @@ class StatusesController < ApplicationController
   def check_account_suspension
     gone if @account.suspended?
   end
+
+  def redirect_to_original
+    redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog?
+  end
 end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 3eb91d830..cc579dbc8 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController
   end
 
   def embed
-    response.headers['X-Frame-Options'] = 'ALLOWALL'
-    return gone if @stream_entry.activity.nil?
-
-    render layout: 'embedded'
+    redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301
   end
 
   private
@@ -38,7 +35,12 @@ class StreamEntriesController < ApplicationController
   end
 
   def set_link_headers
-    response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
+    response.headers['Link'] = LinkHeader.new(
+      [
+        [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
+        [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]],
+      ]
+    )
   end
 
   def set_stream_entry
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 2cd85e185..3001b2ee3 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -12,7 +12,7 @@ class TagsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 9f50d8bdb..6d625e7db 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -5,6 +5,10 @@ module ApplicationHelper
     current_page?(path) ? 'active' : ''
   end
 
+  def active_link_to(label, path, options = {})
+    link_to label, path, options.merge(class: active_nav_class(path))
+  end
+
   def show_landing_strip?
     !user_signed_in? && !single_user_mode?
   end
@@ -38,4 +42,8 @@ module ApplicationHelper
 
     content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
   end
+
+  def opengraph(property, content)
+    tag(:meta, content: content, property: property)
+  end
 end
diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb
deleted file mode 100644
index 848c03fce..000000000
--- a/app/helpers/emoji_helper.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-module EmojiHelper
-  def emojify(text)
-    return text if text.blank?
-
-    text.gsub(emoji_pattern) do |match|
-      emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs
-
-      if emoji
-        emoji
-      else
-        match
-      end
-    end
-  end
-
-  def emoji_pattern
-    @emoji_pattern ||=
-      /(?<=[^[:alnum:]:]|\n|^)
-      (#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')})
-      (?=[^[:alnum:]:]|$)/x
-  end
-end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
new file mode 100644
index 000000000..d82a07332
--- /dev/null
+++ b/app/helpers/jsonld_helper.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module JsonLdHelper
+  def equals_or_includes?(haystack, needle)
+    haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
+  end
+
+  def first_of_value(value)
+    value.is_a?(Array) ? value.first : value
+  end
+
+  def value_or_id(value)
+    value.is_a?(String) || value.nil? ? value : value['id']
+  end
+
+  def supported_context?(json)
+    !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
+  end
+
+  def canonicalize(json)
+    graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
+    graph.dump(:normalize)
+  end
+
+  def fetch_resource(uri)
+    response = build_request(uri).perform
+    return if response.code != 200
+    body_to_json(response.to_s)
+  end
+
+  def body_to_json(body)
+    body.is_a?(String) ? Oj.load(body, mode: :strict) : body
+  rescue Oj::ParseError
+    nil
+  end
+
+  def merge_context(context, new_context)
+    if context.is_a?(Array)
+      context << new_context
+    else
+      [context, new_context]
+    end
+  end
+
+  private
+
+  def build_request(uri)
+    request = Request.new(:get, uri)
+    request.add_headers('Accept' => 'application/activity+json, application/ld+json')
+    request
+  end
+end
diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb
index 8126176ba..f4693358c 100644
--- a/app/helpers/routing_helper.rb
+++ b/app/helpers/routing_helper.rb
@@ -12,6 +12,14 @@ module RoutingHelper
   end
 
   def full_asset_url(source, options = {})
-    Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s
+    source = ActionController::Base.helpers.asset_url(source, options) unless use_storage?
+
+    URI.join(root_url, source).to_s
+  end
+
+  private
+
+  def use_storage?
+    Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift
   end
 end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index af950aa63..14776b354 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -30,6 +30,7 @@ module SettingsHelper
     th: 'ภาษาไทย',
     tr: 'Türkçe',
     uk: 'Українська',
+    zh: '中文',
     'zh-CN': '简体中文',
     'zh-HK': '繁體中文(香港)',
     'zh-TW': '繁體中文(臺灣)',
@@ -39,6 +40,10 @@ module SettingsHelper
     HUMAN_LOCALES[locale]
   end
 
+  def filterable_languages
+    LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?))
+  end
+
   def hash_to_object(hash)
     HashObject.new(hash)
   end
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 4ef7cffb0..445114985 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module StreamEntriesHelper
-  EMBEDDED_CONTROLLER = 'stream_entries'
+  EMBEDDED_CONTROLLER = 'statuses'
   EMBEDDED_ACTION = 'embed'
 
   def display_name(account)
diff --git a/app/javascript/glitch/components/account/header.js b/app/javascript/glitch/components/account/header.js
index b79140c02..bc2ce30f6 100644
--- a/app/javascript/glitch/components/account/header.js
+++ b/app/javascript/glitch/components/account/header.js
@@ -44,7 +44,6 @@ Imports:
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -89,7 +88,7 @@ export default class AccountHeader extends ImmutablePureComponent {
 
   static propTypes = {
     account  : ImmutablePropTypes.map,
-    me       : PropTypes.number.isRequired,
+    me       : PropTypes.string.isRequired,
     onFollow : PropTypes.func.isRequired,
     intl     : PropTypes.object.isRequired,
   };
@@ -117,7 +116,7 @@ then we set the `displayName` to just be the `username` of the account.
       return null;
     }
 
-    let displayName = account.get('display_name');
+    let displayName = account.get('display_name_html');
     let info        = '';
     let actionBtn   = '';
     let following   = false;
@@ -167,16 +166,11 @@ appropriate icon.
     }
 
 /*
-
-`displayNameHTML` processes the `displayName` and prepares it for
-insertion into the document. Meanwhile, we extract the `text` and
+ we extract the `text` and
 `metadata` from our account's `note` using `processBio()`.
 
 */
 
-    const displayNameHTML    = {
-      __html : emojify(escapeTextContentForBrowser(displayName)),
-    };
     const { text, metadata } = processBio(account.get('note'));
 
 /*
@@ -194,15 +188,11 @@ Here, we render our component using all the things we've defined above.
           <div>
             <a href={account.get('url')} target='_blank' rel='noopener'>
               <span className='account__header__avatar'>
-                <Avatar
-                  src={account.get('avatar')}
-                  staticSrc={account.get('avatar_static')}
-                  size={90}
-                />
+                <Avatar account={account} size={90} />
               </span>
               <span
                 className='account__header__display-name'
-                dangerouslySetInnerHTML={displayNameHTML}
+                dangerouslySetInnerHTML={{ __html: displayName }}
               />
             </a>
             <span className='account__header__username'>
diff --git a/app/javascript/glitch/components/local_settings/navigation/item/style.scss b/app/javascript/glitch/components/local_settings/navigation/item/style.scss
index 505c86912..33d7d3744 100644
--- a/app/javascript/glitch/components/local_settings/navigation/item/style.scss
+++ b/app/javascript/glitch/components/local_settings/navigation/item/style.scss
@@ -1,4 +1,4 @@
-@import 'variables';
+@import 'styles/variables';
 
 .glitch.local-settings__navigation__item {
   display: block;
diff --git a/app/javascript/glitch/components/local_settings/navigation/style.scss b/app/javascript/glitch/components/local_settings/navigation/style.scss
index 1cc39e3e9..a610a1212 100644
--- a/app/javascript/glitch/components/local_settings/navigation/style.scss
+++ b/app/javascript/glitch/components/local_settings/navigation/style.scss
@@ -1,4 +1,4 @@
-@import 'variables';
+@import 'styles/variables';
 
 .glitch.local-settings__navigation {
   background: $primary-text-color;
diff --git a/app/javascript/glitch/components/local_settings/page/index.js b/app/javascript/glitch/components/local_settings/page/index.js
index cb041c0b8..338d86333 100644
--- a/app/javascript/glitch/components/local_settings/page/index.js
+++ b/app/javascript/glitch/components/local_settings/page/index.js
@@ -16,6 +16,7 @@ const messages = defineMessages({
   layout_auto: {  id: 'layout.auto', defaultMessage: 'Auto' },
   layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
   layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
+  side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
 });
 
 @injectIntl
@@ -61,6 +62,24 @@ export default class LocalSettingsPage extends React.PureComponent {
         >
           <FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
         </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['side_arm']}
+            id='mastodon-settings--side_arm'
+            options={[
+              { value: 'none', message: intl.formatMessage(messages.side_arm_none) },
+              { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) },
+              { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) },
+              { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) },
+              { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) },
+            ]}
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' />
+          </LocalSettingsPageItem>
+        </section>
       </div>
     ),
     ({ onChange, settings }) => (
diff --git a/app/javascript/glitch/components/local_settings/page/item/style.scss b/app/javascript/glitch/components/local_settings/page/item/style.scss
index e614030c0..da1941b99 100644
--- a/app/javascript/glitch/components/local_settings/page/item/style.scss
+++ b/app/javascript/glitch/components/local_settings/page/item/style.scss
@@ -1,4 +1,4 @@
-@import 'variables';
+@import 'styles/variables';
 
 .glitch.local-settings__page__item {
   select {
diff --git a/app/javascript/glitch/components/local_settings/page/style.scss b/app/javascript/glitch/components/local_settings/page/style.scss
index 7269056c3..53c95ea40 100644
--- a/app/javascript/glitch/components/local_settings/page/style.scss
+++ b/app/javascript/glitch/components/local_settings/page/style.scss
@@ -1,4 +1,4 @@
-@import 'variables';
+@import 'styles/variables';
 
 .glitch.local-settings__page {
   display: block;
diff --git a/app/javascript/glitch/components/local_settings/style.scss b/app/javascript/glitch/components/local_settings/style.scss
index 6f7fcbaa4..54fec47bd 100644
--- a/app/javascript/glitch/components/local_settings/style.scss
+++ b/app/javascript/glitch/components/local_settings/style.scss
@@ -1,4 +1,4 @@
-@import 'variables';
+@import 'styles/variables';
 
 .glitch.local-settings {
   position: relative;
diff --git a/app/javascript/glitch/components/notification/follow.js b/app/javascript/glitch/components/notification/follow.js
index d340e83c8..e2c21bf35 100644
--- a/app/javascript/glitch/components/notification/follow.js
+++ b/app/javascript/glitch/components/notification/follow.js
@@ -1,106 +1,54 @@
-/*
+//  `<NotificationFollow>`
+//  ======================
 
-`<NotificationFollow>`
-======================
+//  * * * * * * *  //
 
-This component renders a follow notification.
+//  Imports
+//  -------
 
-__Props:__
-
- -  __`id` (`PropTypes.number.isRequired`) :__
-    This is the id of the notification.
-
- -  __`onDeleteNotification` (`PropTypes.func.isRequired`) :__
-    The function to call when a notification should be
-    dismissed/deleted.
-
- -  __`account` (`PropTypes.object.isRequired`) :__
-    The account associated with the follow notification, ie the account
-    which followed the user.
-
- -  __`intl` (`PropTypes.object.isRequired`) :__
-    Our internationalization object, inserted by `@injectIntl`.
-
-*/
-
-//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
-
-/*
-
-Imports:
---------
-
-*/
-
-//  Package imports  //
+//  Package imports.
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
-import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
-//  Mastodon imports  //
-import emojify from '../../../mastodon/emoji';
+//  Mastodon imports.
 import Permalink from '../../../mastodon/components/permalink';
 import AccountContainer from '../../../mastodon/containers/account_container';
 
-// Our imports //
+// Our imports.
 import NotificationOverlayContainer from '../notification/overlay/container';
 
-//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
-
-/*
+//  * * * * * * *  //
 
-Implementation:
----------------
-
-*/
+//  Implementation
+//  --------------
 
 export default class NotificationFollow extends ImmutablePureComponent {
 
   static propTypes = {
-    id                   : PropTypes.number.isRequired,
+    id                   : PropTypes.string.isRequired,
     account              : ImmutablePropTypes.map.isRequired,
     notification         : ImmutablePropTypes.map.isRequired,
   };
 
-/*
-
-###  `render()`
-
-This actually renders the component.
-
-*/
-
   render () {
     const { account, notification } = this.props;
 
-/*
-
-`link` is a container for the account's `displayName`, which links to
-the account timeline using a `<Permalink>`.
-
-*/
-
-    const displayName = account.get('display_name') || account.get('username');
-    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
     const link = (
       <Permalink
         className='notification__display-name'
         href={account.get('url')}
         title={account.get('acct')}
         to={`/accounts/${account.get('id')}`}
-        dangerouslySetInnerHTML={displayNameHTML}
+        dangerouslySetInnerHTML={{ __html: displayName }}
       />
     );
 
-/*
-
-We can now render our component.
-
-*/
-
+    //  Renders.
     return (
       <div className='notification notification-follow'>
         <div className='notification__message'>
diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js
index 7c73002c1..f4450d31b 100644
--- a/app/javascript/glitch/components/status/action_bar.js
+++ b/app/javascript/glitch/components/status/action_bar.js
@@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 //  Mastodon imports  //
 import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
 import IconButton from '../../../mastodon/components/icon_button';
-import DropdownMenu from '../../../mastodon/components/dropdown_menu';
+import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -16,6 +16,7 @@ const messages = defineMessages({
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   block: { id: 'account.block', defaultMessage: 'Block @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  share: { id: 'status.share', defaultMessage: 'Share' },
   replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
@@ -24,6 +25,9 @@ const messages = defineMessages({
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
 });
 
 @injectIntl
@@ -43,8 +47,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onMute: PropTypes.func,
     onBlock: PropTypes.func,
     onReport: PropTypes.func,
+    onEmbed: PropTypes.func,
     onMuteConversation: PropTypes.func,
-    me: PropTypes.number,
+    onPin: PropTypes.func,
+    me: PropTypes.string,
     withDismiss: PropTypes.bool,
     intl: PropTypes.object.isRequired,
   };
@@ -61,6 +67,13 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onReply(this.props.status, this.context.router.history);
   }
 
+  handleShareClick = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  }
+
   handleFavouriteClick = () => {
     this.props.onFavourite(this.props.status);
   }
@@ -73,6 +86,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onDelete(this.props.status);
   }
 
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
   handleMentionClick = () => {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
@@ -89,6 +106,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
   }
 
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
   handleReport = () => {
     this.props.onReport(this.props.status);
   }
@@ -99,9 +120,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
 
   render () {
     const { status, me, intl, withDismiss } = this.props;
-    const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
+
     const mutingConversation = status.get('muted');
     const anonymousAccess = !me;
+    const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
 
     let menu = [];
     let reblogIcon = 'retweet';
@@ -109,14 +131,23 @@ export default class StatusActionBar extends ImmutablePureComponent {
     let replyTitle;
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
     menu.push(null);
 
-    if (withDismiss) {
+    if (status.getIn(['account', 'id']) === me || withDismiss) {
       menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
       menu.push(null);
     }
 
     if (status.getIn(['account', 'id']) === me) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
       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 });
@@ -126,14 +157,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
-    /*
-    if (status.get('visibility') === 'direct') {
-      reblogIcon = 'envelope';
-    } else if (status.get('visibility') === 'private') {
-      reblogIcon = 'lock';
-    }
-    */
-
     if (status.get('in_reply_to_id', null) === null) {
       replyIcon = 'reply';
       replyTitle = intl.formatMessage(messages.reply);
@@ -142,14 +165,19 @@ export default class StatusActionBar extends ImmutablePureComponent {
       replyTitle = intl.formatMessage(messages.replyAll);
     }
 
+    const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
+    );
+
     return (
       <div className='status__action-bar'>
         <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
-        <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
-        <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+        <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        {shareButton}
 
         <div className='status__action-bar-dropdown'>
-          <DropdownMenu items={menu} disabled={anonymousAccess} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
+          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
         </div>
 
         <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js
index 1d572e0e7..da2771c0b 100644
--- a/app/javascript/glitch/components/status/container.js
+++ b/app/javascript/glitch/components/status/container.js
@@ -38,11 +38,11 @@ import {
   favourite,
   unreblog,
   unfavourite,
+  pin,
+  unpin,
 } from '../../../mastodon/actions/interactions';
-import {
-  blockAccount,
-  muteAccount,
-} from '../../../mastodon/actions/accounts';
+import { blockAccount } from '../../../mastodon/actions/accounts';
+import { initMuteModal } from '../../../mastodon/actions/mutes';
 import {
   muteStatus,
   unmuteStatus,
@@ -80,10 +80,6 @@ const messages = defineMessages({
     id             : 'confirmations.block.confirm',
     defaultMessage : 'Block',
   },
-  muteConfirm : {
-    id             : 'confirmations.mute.confirm',
-    defaultMessage : 'Mute',
-  },
 });
 
                             /* * * * */
@@ -193,6 +189,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', { url: status.get('url') }));
+  },
+
   onDelete (status) {
     if (!this.deleteModal) {
       dispatch(deleteStatus(status.get('id')));
@@ -230,11 +238,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onMute (account) {
-    dispatch(openModal('CONFIRM', {
-      message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-      confirm: intl.formatMessage(messages.muteConfirm),
-      onConfirm: () => dispatch(muteAccount(account.get('id'))),
-    }));
+    dispatch(initMuteModal(account));
   },
 
   onMuteConversation (status) {
diff --git a/app/javascript/glitch/components/status/content.js b/app/javascript/glitch/components/status/content.js
index 06fe04ce0..06015619b 100644
--- a/app/javascript/glitch/components/status/content.js
+++ b/app/javascript/glitch/components/status/content.js
@@ -1,13 +1,11 @@
 //  Package imports  //
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
 import classnames from 'classnames';
 
 //  Mastodon imports  //
-import emojify from '../../../mastodon/emoji';
 import { isRtl } from '../../../mastodon/rtl';
 import Permalink from '../../../mastodon/components/permalink';
 
@@ -32,7 +30,7 @@ export default class StatusContent extends React.PureComponent {
     const node  = this.node;
     const links = node.querySelectorAll('a');
 
-    for (var i = 0; i < links.length; ++i) {
+    for (let i = 0; i < links.length; ++i) {
       let link    = links[i];
       let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
 
@@ -131,12 +129,8 @@ export default class StatusContent extends React.PureComponent {
       this.state.hidden
     );
 
-    const content = { __html: emojify(status.get('content')) };
-    const spoilerContent = {
-      __html: emojify(escapeTextContentForBrowser(
-        status.get('spoiler_text', '')
-      )),
-    };
+    const content = { __html: status.get('contentHtml') };
+    const spoilerContent = { __html: status.get('spoilerHtml') };
     const directionStyle = { direction: 'ltr' };
     const classNames = classnames('status__content', {
       'status__content--with-action': parseClick && !disabled,
@@ -188,7 +182,7 @@ export default class StatusContent extends React.PureComponent {
       }
 
       return (
-        <div className={classNames} ref={this.setRef}>
+        <div className={classNames}>
           <p
             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
             onMouseDown={this.handleMouseDown}
@@ -205,6 +199,7 @@ export default class StatusContent extends React.PureComponent {
 
           <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
             <div
+              ref={this.setRef}
               style={directionStyle}
               onMouseDown={this.handleMouseDown}
               onMouseUp={this.handleMouseUp}
@@ -218,11 +213,11 @@ export default class StatusContent extends React.PureComponent {
     } else if (parseClick) {
       return (
         <div
-          ref={this.setRef}
           className={classNames}
           style={directionStyle}
         >
           <div
+            ref={this.setRef}
             onMouseDown={this.handleMouseDown}
             onMouseUp={this.handleMouseUp}
             dangerouslySetInnerHTML={content}
@@ -233,11 +228,10 @@ export default class StatusContent extends React.PureComponent {
     } else {
       return (
         <div
-          ref={this.setRef}
           className='status__content'
           style={directionStyle}
         >
-          <div dangerouslySetInnerHTML={content} />
+          <div ref={this.setRef} dangerouslySetInnerHTML={content} />
           {media}
         </div>
       );
diff --git a/app/javascript/glitch/components/status/gallery/item.js b/app/javascript/glitch/components/status/gallery/item.js
index d646825a3..ab4aab8dc 100644
--- a/app/javascript/glitch/components/status/gallery/item.js
+++ b/app/javascript/glitch/components/status/gallery/item.js
@@ -17,6 +17,24 @@ export default class StatusGalleryItem extends React.PureComponent {
     autoPlayGif: PropTypes.bool.isRequired,
   };
 
+  handleMouseEnter = (e) => {
+    if (this.hoverToPlay()) {
+      e.target.play();
+    }
+  }
+
+  handleMouseLeave = (e) => {
+    if (this.hoverToPlay()) {
+      e.target.pause();
+      e.target.currentTime = 0;
+    }
+  }
+
+  hoverToPlay () {
+    const { attachment, autoPlayGif } = this.props;
+    return !autoPlayGif && attachment.get('type') === 'gifv';
+  }
+
   handleClick = (e) => {
     const { index, onClick } = this.props;
 
@@ -112,6 +130,8 @@ export default class StatusGalleryItem extends React.PureComponent {
             role='application'
             src={attachment.get('url')}
             onClick={this.handleClick}
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
             autoPlay={autoPlay}
             loop
             muted
diff --git a/app/javascript/glitch/components/status/header.js b/app/javascript/glitch/components/status/header.js
index 5ce59fba4..f741950b1 100644
--- a/app/javascript/glitch/components/status/header.js
+++ b/app/javascript/glitch/components/status/header.js
@@ -9,41 +9,30 @@ component for better documentation and maintainance by
 
 */
 
-                            /* * * * */
+//  * * * * * * *  //
 
-/*
-
-Imports:
---------
+//  Imports
+//  -------
 
-*/
-
-//  Package imports  //
+//  Package imports.
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { defineMessages, injectIntl } from 'react-intl';
 
-//  Mastodon imports  //
+//  Mastodon imports.
 import Avatar from '../../../mastodon/components/avatar';
 import AvatarOverlay from '../../../mastodon/components/avatar_overlay';
 import DisplayName from '../../../mastodon/components/display_name';
 import IconButton from '../../../mastodon/components/icon_button';
 import VisibilityIcon from './visibility_icon';
 
-                            /* * * * */
-
-/*
-
-Inital setup:
--------------
+//  * * * * * * *  //
 
-The `messages` constant is used to define any messages that we need
-from inside props. In our case, these are the `collapse` and
-`uncollapse` messages used with our collapse/uncollapse buttons.
-
-*/
+//  Initial setup
+//  -------------
 
+//  Messages for use with internationalization stuff.
 const messages = defineMessages({
   collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
   uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
@@ -53,43 +42,10 @@ const messages = defineMessages({
   direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
 });
 
-                            /* * * * */
-
-/*
-
-The `<StatusHeader>` component:
--------------------------------
-
-The `<StatusHeader>` component wraps together the header information
-(avatar, display name) and upper buttons and icons (collapsing, media
-icons) into a single `<header>` element.
-
-###  Props
-
- -  __`account`, `friend` (`ImmutablePropTypes.map`) :__
-    These give the accounts associated with the status. `account` is
-    the author of the post; `friend` will have their avatar appear
-    in the overlay if provided.
-
- -  __`mediaIcon` (`PropTypes.string`) :__
-    If a mediaIcon should be placed in the header, this string
-    specifies it.
-
- -  __`collapsible`, `collapsed` (`PropTypes.bool`) :__
-    These props tell whether a post can be, and is, collapsed.
-
- -  __`parseClick` (`PropTypes.func`) :__
-    This function will be called when the user clicks inside the header
-    information.
-
- -  __`setExpansion` (`PropTypes.func`) :__
-    This function is used to set the expansion state of the post.
-
- -  __`intl` (`PropTypes.object`) :__
-    This is our internationalization object, provided by
-    `injectIntl()`.
+//  * * * * * * *  //
 
-*/
+//  The component
+//  -------------
 
 @injectIntl
 export default class StatusHeader extends React.PureComponent {
@@ -105,18 +61,7 @@ export default class StatusHeader extends React.PureComponent {
     intl: PropTypes.object.isRequired,
   };
 
-/*
-
-###  Implementation
-
-####  `handleCollapsedClick()`.
-
-`handleCollapsedClick()` is just a simple callback for our collapsing
-button. It calls `setExpansion` to set the collapsed state of the
-status.
-
-*/
-
+  //  Handles clicks on collapsed button
   handleCollapsedClick = (e) => {
     const { collapsed, setExpansion } = this.props;
     if (e.button === 0) {
@@ -125,29 +70,13 @@ status.
     }
   }
 
-/*
-
-####  `handleAccountClick()`.
-
-`handleAccountClick()` handles any clicks on the header info. It calls
-`parseClick()` with our `account` as the anticipatory `destination`.
-
-*/
-
+  //  Handles clicks on account name/image
   handleAccountClick = (e) => {
     const { status, parseClick } = this.props;
     parseClick(e, `/accounts/${+status.getIn(['account', 'id'])}`);
   }
 
-/*
-
-####  `render()`.
-
-`render()` actually puts our element on the screen. `<StatusHeader>`
-has a very straightforward rendering process.
-
-*/
-
+  //  Rendering.
   render () {
     const {
       status,
@@ -162,16 +91,28 @@ has a very straightforward rendering process.
 
     return (
       <header className='status__info'>
-        {
-
-/*
-
-We have to include the status icons before the header content because
-it is rendered as a float.
-
-*/
-
-        }
+        <a
+          href={account.get('url')}
+          target='_blank'
+          className='status__avatar'
+          onClick={this.handleAccountClick}
+        >
+          {
+            friend ? (
+              <AvatarOverlay account={account} friend={friend} />
+            ) : (
+              <Avatar account={account} size={48} />
+            )
+          }
+        </a>
+        <a
+          href={account.get('url')}
+          target='_blank'
+          className='status__display-name'
+          onClick={this.handleAccountClick}
+        >
+          <DisplayName account={account} />
+        </a>
         <div className='status__info__icons'>
           {mediaIcon ? (
             <i
@@ -197,39 +138,6 @@ it is rendered as a float.
             />
           ) : null}
         </div>
-        {
-
-/*
-
-This begins our header content. It is all wrapped inside of a link
-which gets handled by `handleAccountClick`. We use an `<AvatarOverlay>`
-if we have a `friend` and a normal `<Avatar>` if we don't.
-
-*/
-
-        }
-        <a
-          href={account.get('url')}
-          target='_blank'
-          className='status__display-name'
-          onClick={this.handleAccountClick}
-        >
-          <div className='status__avatar'>{
-            friend ? (
-              <AvatarOverlay
-                staticSrc={account.get('avatar_static')}
-                overlaySrc={friend.get('avatar_static')}
-              />
-            ) : (
-              <Avatar
-                src={account.get('avatar')}
-                staticSrc={account.get('avatar_static')}
-                size={48}
-              />
-            )
-          }</div>
-          <DisplayName account={account} />
-        </a>
 
       </header>
     );
diff --git a/app/javascript/glitch/components/status/index.js b/app/javascript/glitch/components/status/index.js
index 55e6f1876..9e758793c 100644
--- a/app/javascript/glitch/components/status/index.js
+++ b/app/javascript/glitch/components/status/index.js
@@ -155,20 +155,23 @@ export default class Status extends ImmutablePureComponent {
   };
 
   static propTypes = {
-    id                          : PropTypes.number,
+    id                          : PropTypes.string,
     status                      : ImmutablePropTypes.map,
     account                     : ImmutablePropTypes.map,
     settings                    : ImmutablePropTypes.map,
     notification                : ImmutablePropTypes.map,
-    me                          : PropTypes.number,
+    me                          : PropTypes.string,
     onFavourite                 : PropTypes.func,
     onReblog                    : PropTypes.func,
     onModalReblog               : PropTypes.func,
     onDelete                    : PropTypes.func,
+    onPin                       : PropTypes.func,
     onMention                   : PropTypes.func,
     onMute                      : PropTypes.func,
     onMuteConversation          : PropTypes.func,
     onBlock                     : PropTypes.func,
+    onEmbed                     : PropTypes.func,
+    onHeightChange              : PropTypes.func,
     onReport                    : PropTypes.func,
     onOpenMedia                 : PropTypes.func,
     onOpenVideo                 : PropTypes.func,
diff --git a/app/javascript/glitch/components/status/prepend.js b/app/javascript/glitch/components/status/prepend.js
index 6213e4c8d..8c0aed0f4 100644
--- a/app/javascript/glitch/components/status/prepend.js
+++ b/app/javascript/glitch/components/status/prepend.js
@@ -22,12 +22,8 @@ Imports:
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
 import { FormattedMessage } from 'react-intl';
 
-//  Mastodon imports  //
-import emojify from '../../../mastodon/emoji';
-
                             /* * * * */
 
 /*
@@ -99,9 +95,7 @@ generate the message.
       >
         <b
           dangerouslySetInnerHTML={{
-            __html : emojify(escapeTextContentForBrowser(
-              account.get('display_name') || account.get('username')
-            )),
+            __html : account.get('display_name_html') || account.get('username'),
           }}
         />
       </a>
diff --git a/app/javascript/glitch/locales/en.json b/app/javascript/glitch/locales/en.json
index 7ec381de1..18e412356 100644
--- a/app/javascript/glitch/locales/en.json
+++ b/app/javascript/glitch/locales/en.json
@@ -5,6 +5,7 @@
   "layout.desktop": "Desktop",
   "layout.mobile": "Mobile",
   "navigation_bar.app_settings": "App settings",
+  "getting_started.onboarding": "Show me around",
   "onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
   "onboarding.page_one.welcome": "Welcome to {domain}!",
   "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.",
diff --git a/app/javascript/glitch/reducers/local_settings.js b/app/javascript/glitch/reducers/local_settings.js
index 386d59ceb..813e130ca 100644
--- a/app/javascript/glitch/reducers/local_settings.js
+++ b/app/javascript/glitch/reducers/local_settings.js
@@ -52,6 +52,7 @@ const initialState = ImmutableMap({
   layout    : 'auto',
   stretch   : true,
   navbar_under : false,
+  side_arm  : 'none',
   collapsed : ImmutableMap({
     enabled     : true,
     auto        : ImmutableMap({
diff --git a/app/javascript/glitch/util/bio_metadata.js b/app/javascript/glitch/util/bio_metadata.js
index 0c8195e9d..eb3ad01fe 100644
--- a/app/javascript/glitch/util/bio_metadata.js
+++ b/app/javascript/glitch/util/bio_metadata.js
@@ -74,17 +74,27 @@ functions are:
 
 \*********************************************************************/
 
+/*  "u" FLAG COMPATABILITY  */
+
+let compat_mode = false;
+try {
+  new RegExp('.', 'u');
+} catch (e) {
+  compat_mode = true;
+}
+
 /*  CONVENIENCE FUNCTIONS  */
 
-const unirex = str => new RegExp(str, 'u');
+const unirex = str => compat_mode ? new RegExp(str) : new RegExp(str, 'u');
 const rexstr = exp => '(?:' + exp.source + ')';
 
 /*  CHARACTER CLASSES  */
 
 const DOCUMENT_START    = /^/;
 const DOCUMENT_END      = /$/;
-const ALLOWED_CHAR      =  //  `c-printable` in the YAML 1.2 spec.
-  /[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]/u;
+const ALLOWED_CHAR      =  unirex( //  `c-printable` in the YAML 1.2 spec.
+    compat_mode ? '[\t\n\r\x20-\x7e\x85\xa0-\ufffd]' : '[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]'
+  );
 const WHITE_SPACE       = /[ \t]/;
 const INDENTATION       = / */;  //  Indentation must be only spaces.
 const LINE_BREAK        = /\r?\n|\r|<br\s*\/?>/;
diff --git a/app/javascript/images/logo.svg b/app/javascript/images/logo.svg
index 4b72b3ac8..034a9c221 100644
--- a/app/javascript/images/logo.svg
+++ b/app/javascript/images/logo.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="61.076954mm" height="65.47831mm" viewBox="0 0 216.4144 232.00976"><path d="M211.80734 139.0875c-3.18125 16.36625-28.4925 34.2775-57.5625 37.74875-15.15875 1.80875-30.08375 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.39125 27.9425 21.11625.7225 39.91875-5.20625 39.91875-5.20625l.8675 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23234 213.82 1.40609 165.31125.20859 116.09125c-.365-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67234 3.45375 78.20359.2425 107.86484 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.975 14.7525 32.975 65.0825 0 0 .41375 37.13375-4.59875 62.915" fill="#3088d4"/><path d="M177.50984 80.077v60.94125h-24.14375v-59.15c0-12.46875-5.24625-18.7975-15.74-18.7975-11.6025 0-17.4175 7.5075-17.4175 22.3525v32.37625H96.20734V85.42325c0-14.845-5.81625-22.3525-17.41875-22.3525-10.49375 0-15.74 6.32875-15.74 18.7975v59.15H38.90484V80.077c0-12.455 3.17125-22.3525 9.54125-29.675 6.56875-7.3225 15.17125-11.07625 25.85-11.07625 12.355 0 21.71125 4.74875 27.8975 14.2475l6.01375 10.08125 6.015-10.08125c6.185-9.49875 15.54125-14.2475 27.8975-14.2475 10.6775 0 19.28 3.75375 25.85 11.07625 6.36875 7.3225 9.54 17.22 9.54 29.675" fill="#fff"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M211.80734 139.0875c-3.18125 16.36625-28.4925 34.2775-57.5625 37.74875-15.15875 1.80875-30.08375 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.39125 27.9425 21.11625.7225 39.91875-5.20625 39.91875-5.20625l.8675 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23234 213.82 1.40609 165.31125.20859 116.09125c-.365-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67234 3.45375 78.20359.2425 107.86484 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.975 14.7525 32.975 65.0825 0 0 .41375 37.13375-4.59875 62.915" fill="#3088d4"/><path d="M177.50984 80.077v60.94125h-24.14375v-59.15c0-12.46875-5.24625-18.7975-15.74-18.7975-11.6025 0-17.4175 7.5075-17.4175 22.3525v32.37625H96.20734V85.42325c0-14.845-5.81625-22.3525-17.41875-22.3525-10.49375 0-15.74 6.32875-15.74 18.7975v59.15H38.90484V80.077c0-12.455 3.17125-22.3525 9.54125-29.675 6.56875-7.3225 15.17125-11.07625 25.85-11.07625 12.355 0 21.71125 4.74875 27.8975 14.2475l6.01375 10.08125 6.015-10.08125c6.185-9.49875 15.54125-14.2475 27.8975-14.2475 10.6775 0 19.28 3.75375 25.85 11.07625 6.36875 7.3225 9.54 17.22 9.54 29.675" fill="#fff"/></svg>
diff --git a/app/javascript/images/logo_alt.svg b/app/javascript/images/logo_alt.svg
index e88ca7418..102d4c787 100644
--- a/app/javascript/images/logo_alt.svg
+++ b/app/javascript/images/logo_alt.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="61.077141mm" height="65.47831mm" viewBox="0 0 216.41507 232.00976"><path d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915" fill="#3088d4"/><path d="M65.68743 96.45938c0 9.01375-7.3075 16.32125-16.3225 16.32125-9.01375 0-16.32-7.3075-16.32-16.32125 0-9.01375 7.30625-16.3225 16.32-16.3225 9.015 0 16.3225 7.30875 16.3225 16.3225M124.52893 96.45938c0 9.01375-7.30875 16.32125-16.3225 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.3225 7.30875 16.3225 16.3225M183.36933 96.45938c0 9.01375-7.3075 16.32125-16.32125 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.32125 7.30875 16.32125 16.3225" fill="#fff"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.41507 232.00976"><path d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915" fill="#3088d4"/><path d="M65.68743 96.45938c0 9.01375-7.3075 16.32125-16.3225 16.32125-9.01375 0-16.32-7.3075-16.32-16.32125 0-9.01375 7.30625-16.3225 16.32-16.3225 9.015 0 16.3225 7.30875 16.3225 16.3225M124.52893 96.45938c0 9.01375-7.30875 16.32125-16.3225 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.3225 7.30875 16.3225 16.3225M183.36933 96.45938c0 9.01375-7.3075 16.32125-16.32125 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.32125 7.30875 16.32125 16.3225" fill="#fff"/></svg>
diff --git a/app/javascript/images/logo_full.svg b/app/javascript/images/logo_full.svg
index 8b1328e8c..c33883342 100644
--- a/app/javascript/images/logo_full.svg
+++ b/app/javascript/images/logo_full.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 713.35878 175.8678" height="49.633801mm" width="201.3257mm"><path d="M160.55476 105.43125c-2.4125 12.40625-21.5975 25.9825-43.63375 28.61375-11.49125 1.3725-22.80375 2.63125-34.8675 2.07875-19.73-.90375-35.2975-4.71-35.2975-4.71 0 1.92125.11875 3.75.355 5.46 2.565 19.47 19.3075 20.6375 35.16625 21.18125 16.00625.5475 30.2575-3.9475 30.2575-3.9475l.65875 14.4725s-11.19625 6.01125-31.14 7.11625c-10.99875.605-24.65375-.27625-40.56-4.485C6.99851 162.08 1.06601 125.31.15851 88-.11899 76.9225.05226 66.47625.05226 57.74125c0-38.1525 24.99625-49.335 24.99625-49.335C37.65226 2.6175 59.27976.18375 81.76351 0h.5525c22.48375.18375 44.125 2.6175 56.72875 8.40625 0 0 24.99625 11.1825 24.99625 49.335 0 0 .3125 28.1475-3.48625 47.69" fill="#3088d4"/><path d="M34.65751 48.494c0-5.55375 4.5025-10.055 10.055-10.055 5.55375 0 10.055 4.50125 10.055 10.055 0 5.5525-4.50125 10.055-10.055 10.055-5.5525 0-10.055-4.5025-10.055-10.055M178.86476 60.69975v46.195h-18.30125v-44.8375c0-9.4525-3.9775-14.24875-11.9325-14.24875-8.79375 0-13.2025 5.69125-13.2025 16.94375V89.2935h-18.19375V64.75225c0-11.2525-4.40875-16.94375-13.2025-16.94375-7.955 0-11.9325 4.79625-11.9325 14.24875v44.8375H73.79851v-46.195c0-9.44125 2.40375-16.94375 7.2325-22.495 4.98-5.55 11.50125-8.395 19.595-8.395 9.36625 0 16.45875 3.59875 21.14625 10.79875l4.56 7.6425 4.55875-7.6425c4.68875-7.2 11.78-10.79875 21.1475-10.79875 8.09375 0 14.61375 2.845 19.59375 8.395 4.82875 5.55125 7.2325 13.05375 7.2325 22.495M241.91276 83.663625c3.77625-3.99 5.595-9.015 5.595-15.075 0-6.06-1.81875-11.085-5.595-14.9275-3.63625-3.99125-8.25375-5.91125-13.84875-5.91125-5.59625 0-10.2125 1.92-13.84875 5.91125-3.6375 3.8425-5.45625 8.8675-5.45625 14.9275 0 6.06 1.81875 11.085 5.45625 15.075 3.63625 3.8425 8.2525 5.76375 13.84875 5.76375 5.595 0 10.2125-1.92125 13.84875-5.76375m5.595-52.025h18.04625v73.9h-18.04625v-8.72125c-5.455 7.2425-13.01 10.79-22.80125 10.79-9.3725 0-17.34625-3.695-24.06125-11.23375-6.57375-7.5375-9.93125-16.84875-9.93125-27.785 0-10.78875 3.3575-20.10125 9.93125-27.63875 6.715-7.5375 14.68875-11.38 24.06125-11.38 9.79125 0 17.34625 3.5475 22.80125 10.78875v-8.72zM326.26951 67.258625c5.315 3.99 7.97375 9.60625 7.83375 16.7 0 7.53875-2.65875 13.45-8.11375 17.58875-5.45625 3.99125-12.03 6.06-20.00375 6.06-14.40875 0-24.20125-5.9125-29.3775-17.58875l15.66875-9.31c2.0975 6.35375 6.71375 9.60625 13.70875 9.60625 6.43375 0 9.6525-2.07 9.6525-6.35625 0-3.10375-4.1975-5.91125-12.73-8.1275-3.21875-.8875-5.87625-1.77375-7.97375-2.51375-2.9375-1.18125-5.455-2.5125-7.55375-4.1375-5.17625-3.99-7.83375-9.3125-7.83375-16.11 0-7.2425 2.5175-13.00625 7.55375-17.145 5.17625-4.28625 11.47-6.355 19.025-6.355 12.03 0 20.84375 5.1725 26.5775 15.66625l-15.38625 8.8675c-2.23875-5.02375-6.015-7.53625-11.19125-7.53625-5.45625 0-8.11375 2.06875-8.11375 6.05875 0 3.10375 4.19625 5.91125 12.73 8.12875 6.575 1.4775 11.75 3.695 15.5275 6.50375M383.626635 49.966125h-15.8075v30.7425c0 3.695 1.4 5.91125 4.0575 6.945 1.95875.74 5.875.8875 11.75.59125v17.29375c-12.16875 1.4775-20.9825.295-26.15875-3.69625-5.175-3.8425-7.69375-10.93625-7.69375-21.13375v-30.7425h-12.17v-18.3275h12.17v-14.9275l18.045-5.76375v20.69125h15.8075v18.3275zM441.124885 83.2205c3.6375-3.84375 5.455-8.72125 5.455-14.6325 0-5.91125-1.8175-10.78875-5.455-14.63125-3.6375-3.84375-8.11375-5.76375-13.57-5.76375-5.455 0-9.93125 1.92-13.56875 5.76375-3.4975 3.99-5.31625 8.8675-5.31625 14.63125 0 5.765 1.81875 10.6425 5.31625 14.6325 3.6375 3.8425 8.11375 5.76375 13.56875 5.76375 5.45625 0 9.9325-1.92125 13.57-5.76375m-39.86875 13.15375c-7.13375-7.5375-10.63125-16.70125-10.63125-27.78625 0-10.9375 3.4975-20.1 10.63125-27.6375 7.13375-7.5375 15.9475-11.38 26.29875-11.38 10.3525 0 19.165 3.8425 26.3 11.38 7.135 7.5375 10.77125 16.84875 10.77125 27.6375 0 10.9375-3.63625 20.24875-10.77125 27.78625-7.135 7.53875-15.8075 11.2325-26.3 11.2325-10.49125 0-19.165-3.69375-26.29875-11.2325M524.92126 83.663625c3.6375-3.99 5.455-9.015 5.455-15.075 0-6.06-1.8175-11.085-5.455-14.9275-3.63625-3.99125-8.25375-5.91125-13.84875-5.91125-5.59625 0-10.2125 1.92-13.98875 5.91125-3.63625 3.8425-5.45625 8.8675-5.45625 14.9275 0 6.06 1.82 11.085 5.45625 15.075 3.77625 3.8425 8.5325 5.76375 13.98875 5.76375 5.595 0 10.2125-1.92125 13.84875-5.76375m5.455-81.585h18.04625v103.46h-18.04625v-8.72125c-5.315 7.2425-12.87 10.79-22.66125 10.79-9.3725 0-17.485-3.695-24.2-11.23375-6.575-7.5375-9.9325-16.84875-9.9325-27.785 0-10.78875 3.3575-20.10125 9.9325-27.63875 6.715-7.5375 14.8275-11.38 24.2-11.38 9.79125 0 17.34625 3.5475 22.66125 10.78875v-38.28zM611.79626 83.2205c3.63625-3.84375 5.455-8.72125 5.455-14.6325 0-5.91125-1.81875-10.78875-5.455-14.63125-3.6375-3.84375-8.11375-5.76375-13.57-5.76375-5.455 0-9.9325 1.92-13.56875 5.76375-3.49875 3.99-5.31625 8.8675-5.31625 14.63125 0 5.765 1.8175 10.6425 5.31625 14.6325 3.63625 3.8425 8.11375 5.76375 13.56875 5.76375 5.45625 0 9.9325-1.92125 13.57-5.76375m-39.86875 13.15375c-7.135-7.5375-10.63125-16.70125-10.63125-27.78625 0-10.9375 3.49625-20.1 10.63125-27.6375 7.135-7.5375 15.9475-11.38 26.29875-11.38 10.3525 0 19.165 3.8425 26.3 11.38 7.135 7.5375 10.77125 16.84875 10.77125 27.6375 0 10.9375-3.63625 20.24875-10.77125 27.78625-7.135 7.53875-15.8075 11.2325-26.3 11.2325-10.49125 0-19.16375-3.69375-26.29875-11.2325M713.35876 60.163875v45.37375h-18.04625v-43.00875c0-4.8775-1.25875-8.5725-3.77625-11.38-2.37875-2.5125-5.73625-3.84375-10.0725-3.84375-10.2125 0-15.3875 6.06-15.3875 18.3275v39.905h-18.04625v-73.89875h18.04625v8.27625c4.33625-6.94625 11.19-10.345 20.84375-10.345 7.69375 0 13.98875 2.66 18.885 8.12875 5.035 5.46875 7.55375 12.85875 7.55375 22.465" fill="#fff"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 713.35878 175.8678"><path d="M160.55476 105.43125c-2.4125 12.40625-21.5975 25.9825-43.63375 28.61375-11.49125 1.3725-22.80375 2.63125-34.8675 2.07875-19.73-.90375-35.2975-4.71-35.2975-4.71 0 1.92125.11875 3.75.355 5.46 2.565 19.47 19.3075 20.6375 35.16625 21.18125 16.00625.5475 30.2575-3.9475 30.2575-3.9475l.65875 14.4725s-11.19625 6.01125-31.14 7.11625c-10.99875.605-24.65375-.27625-40.56-4.485C6.99851 162.08 1.06601 125.31.15851 88-.11899 76.9225.05226 66.47625.05226 57.74125c0-38.1525 24.99625-49.335 24.99625-49.335C37.65226 2.6175 59.27976.18375 81.76351 0h.5525c22.48375.18375 44.125 2.6175 56.72875 8.40625 0 0 24.99625 11.1825 24.99625 49.335 0 0 .3125 28.1475-3.48625 47.69" fill="#3088d4"/><path d="M34.65751 48.494c0-5.55375 4.5025-10.055 10.055-10.055 5.55375 0 10.055 4.50125 10.055 10.055 0 5.5525-4.50125 10.055-10.055 10.055-5.5525 0-10.055-4.5025-10.055-10.055M178.86476 60.69975v46.195h-18.30125v-44.8375c0-9.4525-3.9775-14.24875-11.9325-14.24875-8.79375 0-13.2025 5.69125-13.2025 16.94375V89.2935h-18.19375V64.75225c0-11.2525-4.40875-16.94375-13.2025-16.94375-7.955 0-11.9325 4.79625-11.9325 14.24875v44.8375H73.79851v-46.195c0-9.44125 2.40375-16.94375 7.2325-22.495 4.98-5.55 11.50125-8.395 19.595-8.395 9.36625 0 16.45875 3.59875 21.14625 10.79875l4.56 7.6425 4.55875-7.6425c4.68875-7.2 11.78-10.79875 21.1475-10.79875 8.09375 0 14.61375 2.845 19.59375 8.395 4.82875 5.55125 7.2325 13.05375 7.2325 22.495M241.91276 83.663625c3.77625-3.99 5.595-9.015 5.595-15.075 0-6.06-1.81875-11.085-5.595-14.9275-3.63625-3.99125-8.25375-5.91125-13.84875-5.91125-5.59625 0-10.2125 1.92-13.84875 5.91125-3.6375 3.8425-5.45625 8.8675-5.45625 14.9275 0 6.06 1.81875 11.085 5.45625 15.075 3.63625 3.8425 8.2525 5.76375 13.84875 5.76375 5.595 0 10.2125-1.92125 13.84875-5.76375m5.595-52.025h18.04625v73.9h-18.04625v-8.72125c-5.455 7.2425-13.01 10.79-22.80125 10.79-9.3725 0-17.34625-3.695-24.06125-11.23375-6.57375-7.5375-9.93125-16.84875-9.93125-27.785 0-10.78875 3.3575-20.10125 9.93125-27.63875 6.715-7.5375 14.68875-11.38 24.06125-11.38 9.79125 0 17.34625 3.5475 22.80125 10.78875v-8.72zM326.26951 67.258625c5.315 3.99 7.97375 9.60625 7.83375 16.7 0 7.53875-2.65875 13.45-8.11375 17.58875-5.45625 3.99125-12.03 6.06-20.00375 6.06-14.40875 0-24.20125-5.9125-29.3775-17.58875l15.66875-9.31c2.0975 6.35375 6.71375 9.60625 13.70875 9.60625 6.43375 0 9.6525-2.07 9.6525-6.35625 0-3.10375-4.1975-5.91125-12.73-8.1275-3.21875-.8875-5.87625-1.77375-7.97375-2.51375-2.9375-1.18125-5.455-2.5125-7.55375-4.1375-5.17625-3.99-7.83375-9.3125-7.83375-16.11 0-7.2425 2.5175-13.00625 7.55375-17.145 5.17625-4.28625 11.47-6.355 19.025-6.355 12.03 0 20.84375 5.1725 26.5775 15.66625l-15.38625 8.8675c-2.23875-5.02375-6.015-7.53625-11.19125-7.53625-5.45625 0-8.11375 2.06875-8.11375 6.05875 0 3.10375 4.19625 5.91125 12.73 8.12875 6.575 1.4775 11.75 3.695 15.5275 6.50375M383.626635 49.966125h-15.8075v30.7425c0 3.695 1.4 5.91125 4.0575 6.945 1.95875.74 5.875.8875 11.75.59125v17.29375c-12.16875 1.4775-20.9825.295-26.15875-3.69625-5.175-3.8425-7.69375-10.93625-7.69375-21.13375v-30.7425h-12.17v-18.3275h12.17v-14.9275l18.045-5.76375v20.69125h15.8075v18.3275zM441.124885 83.2205c3.6375-3.84375 5.455-8.72125 5.455-14.6325 0-5.91125-1.8175-10.78875-5.455-14.63125-3.6375-3.84375-8.11375-5.76375-13.57-5.76375-5.455 0-9.93125 1.92-13.56875 5.76375-3.4975 3.99-5.31625 8.8675-5.31625 14.63125 0 5.765 1.81875 10.6425 5.31625 14.6325 3.6375 3.8425 8.11375 5.76375 13.56875 5.76375 5.45625 0 9.9325-1.92125 13.57-5.76375m-39.86875 13.15375c-7.13375-7.5375-10.63125-16.70125-10.63125-27.78625 0-10.9375 3.4975-20.1 10.63125-27.6375 7.13375-7.5375 15.9475-11.38 26.29875-11.38 10.3525 0 19.165 3.8425 26.3 11.38 7.135 7.5375 10.77125 16.84875 10.77125 27.6375 0 10.9375-3.63625 20.24875-10.77125 27.78625-7.135 7.53875-15.8075 11.2325-26.3 11.2325-10.49125 0-19.165-3.69375-26.29875-11.2325M524.92126 83.663625c3.6375-3.99 5.455-9.015 5.455-15.075 0-6.06-1.8175-11.085-5.455-14.9275-3.63625-3.99125-8.25375-5.91125-13.84875-5.91125-5.59625 0-10.2125 1.92-13.98875 5.91125-3.63625 3.8425-5.45625 8.8675-5.45625 14.9275 0 6.06 1.82 11.085 5.45625 15.075 3.77625 3.8425 8.5325 5.76375 13.98875 5.76375 5.595 0 10.2125-1.92125 13.84875-5.76375m5.455-81.585h18.04625v103.46h-18.04625v-8.72125c-5.315 7.2425-12.87 10.79-22.66125 10.79-9.3725 0-17.485-3.695-24.2-11.23375-6.575-7.5375-9.9325-16.84875-9.9325-27.785 0-10.78875 3.3575-20.10125 9.9325-27.63875 6.715-7.5375 14.8275-11.38 24.2-11.38 9.79125 0 17.34625 3.5475 22.66125 10.78875v-38.28zM611.79626 83.2205c3.63625-3.84375 5.455-8.72125 5.455-14.6325 0-5.91125-1.81875-10.78875-5.455-14.63125-3.6375-3.84375-8.11375-5.76375-13.57-5.76375-5.455 0-9.9325 1.92-13.56875 5.76375-3.49875 3.99-5.31625 8.8675-5.31625 14.63125 0 5.765 1.8175 10.6425 5.31625 14.6325 3.63625 3.8425 8.11375 5.76375 13.56875 5.76375 5.45625 0 9.9325-1.92125 13.57-5.76375m-39.86875 13.15375c-7.135-7.5375-10.63125-16.70125-10.63125-27.78625 0-10.9375 3.49625-20.1 10.63125-27.6375 7.135-7.5375 15.9475-11.38 26.29875-11.38 10.3525 0 19.165 3.8425 26.3 11.38 7.135 7.5375 10.77125 16.84875 10.77125 27.6375 0 10.9375-3.63625 20.24875-10.77125 27.78625-7.135 7.53875-15.8075 11.2325-26.3 11.2325-10.49125 0-19.16375-3.69375-26.29875-11.2325M713.35876 60.163875v45.37375h-18.04625v-43.00875c0-4.8775-1.25875-8.5725-3.77625-11.38-2.37875-2.5125-5.73625-3.84375-10.0725-3.84375-10.2125 0-15.3875 6.06-15.3875 18.3275v39.905h-18.04625v-73.89875h18.04625v8.27625c4.33625-6.94625 11.19-10.345 20.84375-10.345 7.69375 0 13.98875 2.66 18.885 8.12875 5.035 5.46875 7.55375 12.85875 7.55375 22.465" fill="#fff"/></svg>
diff --git a/app/javascript/images/mastodon_small.jpg b/app/javascript/images/mastodon_small.jpg
deleted file mode 100644
index 9c88ce3f7..000000000
--- a/app/javascript/images/mastodon_small.jpg
+++ /dev/null
Binary files differdiff --git a/app/javascript/images/preview.jpg b/app/javascript/images/preview.jpg
new file mode 100644
index 000000000..ec2856748
--- /dev/null
+++ b/app/javascript/images/preview.jpg
Binary files differdiff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index 03e3d3d9f..fc47110e3 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -240,11 +240,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id) {
+export function muteAccount(id, notifications) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
       // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
       dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
     }).catch(error => {
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 5a486f9bb..20cb09f58 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -1,6 +1,12 @@
 import api from '../api';
+import { emojiIndex } from 'emoji-mart';
 
-import { updateTimeline } from './timelines';
+import {
+  updateTimeline,
+  refreshHomeTimeline,
+  refreshCommunityTimeline,
+  refreshPublicTimeline,
+} from './timelines';
 
 export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
 export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST';
@@ -98,16 +104,20 @@ export function submitCompose() {
       dispatch(submitComposeSuccess({ ...response.data }));
 
       // To make the app more responsive, immediately get the status into the columns
-      dispatch(updateTimeline('home', { ...response.data }));
 
-      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
-        if (getState().getIn(['timelines', 'community', 'loaded'])) {
-          dispatch(updateTimeline('community', { ...response.data }));
+      const insertOrRefresh = (timelineId, refreshAction) => {
+        if (getState().getIn(['timelines', timelineId, 'online'])) {
+          dispatch(updateTimeline(timelineId, { ...response.data }));
+        } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
+          dispatch(refreshAction());
         }
+      };
 
-        if (getState().getIn(['timelines', 'public', 'loaded'])) {
-          dispatch(updateTimeline('public', { ...response.data }));
-        }
+      insertOrRefresh('home', refreshHomeTimeline);
+
+      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+        insertOrRefresh('community', refreshCommunityTimeline);
+        insertOrRefresh('public', refreshPublicTimeline);
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
@@ -204,19 +214,33 @@ export function clearComposeSuggestions() {
 
 export function fetchComposeSuggestions(token) {
   return (dispatch, getState) => {
+    if (token[0] === ':') {
+      const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 });
+      dispatch(readyComposeSuggestionsEmojis(token, results));
+      return;
+    }
+
     api(getState).get('/api/v1/accounts/search', {
       params: {
-        q: token,
+        q: token.slice(1),
         resolve: false,
         limit: 4,
       },
     }).then(response => {
-      dispatch(readyComposeSuggestions(token, response.data));
+      dispatch(readyComposeSuggestionsAccounts(token, response.data));
     });
   };
 };
 
-export function readyComposeSuggestions(token, accounts) {
+export function readyComposeSuggestionsEmojis(token, emojis) {
+  return {
+    type: COMPOSE_SUGGESTIONS_READY,
+    token,
+    emojis,
+  };
+};
+
+export function readyComposeSuggestionsAccounts(token, accounts) {
   return {
     type: COMPOSE_SUGGESTIONS_READY,
     token,
@@ -224,13 +248,21 @@ export function readyComposeSuggestions(token, accounts) {
   };
 };
 
-export function selectComposeSuggestion(position, token, accountId) {
+export function selectComposeSuggestion(position, token, suggestion) {
   return (dispatch, getState) => {
-    const completion = getState().getIn(['accounts', accountId, 'acct']);
+    let completion, startPosition;
+
+    if (typeof suggestion === 'object' && suggestion.id) {
+      completion    = suggestion.native || suggestion.colons;
+      startPosition = position - 1;
+    } else {
+      completion    = getState().getIn(['accounts', suggestion, 'acct']);
+      startPosition = position;
+    }
 
     dispatch({
       type: COMPOSE_SUGGESTION_SELECT,
-      position,
+      position: startPosition,
       token,
       completion,
     });
diff --git a/app/javascript/mastodon/actions/height_cache.js b/app/javascript/mastodon/actions/height_cache.js
new file mode 100644
index 000000000..4c752993f
--- /dev/null
+++ b/app/javascript/mastodon/actions/height_cache.js
@@ -0,0 +1,17 @@
+export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
+export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
+
+export function setHeight (key, id, height) {
+  return {
+    type: HEIGHT_CACHE_SET,
+    key,
+    id,
+    height,
+  };
+};
+
+export function clearHeight () {
+  return {
+    type: HEIGHT_CACHE_CLEAR,
+  };
+};
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
index 36eec4934..7b5f4bd9c 100644
--- a/app/javascript/mastodon/actions/interactions.js
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
 export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
 export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';
 
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL    = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL    = 'UNPIN_FAIL';
+
 export function reblog(status) {
   return function (dispatch, getState) {
     dispatch(reblogRequest(status));
@@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) {
     error,
   };
 };
+
+export function pin(status) {
+  return (dispatch, getState) => {
+    dispatch(pinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+      dispatch(pinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(pinFail(status, error));
+    });
+  };
+};
+
+export function pinRequest(status) {
+  return {
+    type: PIN_REQUEST,
+    status,
+  };
+};
+
+export function pinSuccess(status, response) {
+  return {
+    type: PIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function pinFail(status, error) {
+  return {
+    type: PIN_FAIL,
+    status,
+    error,
+  };
+};
+
+export function unpin (status) {
+  return (dispatch, getState) => {
+    dispatch(unpinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+      dispatch(unpinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unpinFail(status, error));
+    });
+  };
+};
+
+export function unpinRequest(status) {
+  return {
+    type: UNPIN_REQUEST,
+    status,
+  };
+};
+
+export function unpinSuccess(status, response) {
+  return {
+    type: UNPIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function unpinFail(status, error) {
+  return {
+    type: UNPIN_FAIL,
+    status,
+    error,
+  };
+};
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
index febda7219..3474250fe 100644
--- a/app/javascript/mastodon/actions/mutes.js
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -1,5 +1,6 @@
 import api, { getLinks } from '../api';
 import { fetchRelationships } from './accounts';
+import { openModal } from '../../mastodon/actions/modal';
 
 export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
 export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
@@ -9,6 +10,9 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
 export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
 export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
 
+export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
+export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+
 export function fetchMutes() {
   return (dispatch, getState) => {
     dispatch(fetchMutesRequest());
@@ -80,3 +84,20 @@ export function expandMutesFail(error) {
     error,
   };
 };
+
+export function initMuteModal(account) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_INIT_MODAL,
+      account,
+    });
+
+    dispatch(openModal('MUTE'));
+  };
+}
+
+export function toggleHideNotifications() {
+  return dispatch => {
+    dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
+  };
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js
new file mode 100644
index 000000000..01bf8930b
--- /dev/null
+++ b/app/javascript/mastodon/actions/pin_statuses.js
@@ -0,0 +1,39 @@
+import api from '../api';
+
+export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
+export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
+export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
+
+export function fetchPinnedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchPinnedStatusesRequest());
+
+    const accountId = getState().getIn(['meta', 'me']);
+    api(getState).get(`/api/v1/accounts/${accountId}/statuses`, { params: { pinned: true } }).then(response => {
+      dispatch(fetchPinnedStatusesSuccess(response.data, null));
+    }).catch(error => {
+      dispatch(fetchPinnedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchPinnedStatusesRequest() {
+  return {
+    type: PINNED_STATUSES_FETCH_REQUEST,
+  };
+};
+
+export function fetchPinnedStatusesSuccess(statuses, next) {
+  return {
+    type: PINNED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next,
+  };
+};
+
+export function fetchPinnedStatusesFail(error) {
+  return {
+    type: PINNED_STATUSES_FETCH_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
index 0597d265e..a1db0fdd5 100644
--- a/app/javascript/mastodon/actions/store.js
+++ b/app/javascript/mastodon/actions/store.js
@@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
 
 const convertState = rawState =>
   fromJS(rawState, (k, v) =>
-    Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
-      Number.isNaN(x * 1) ? x : x * 1));
+    Iterable.isIndexed(v) ? v.toList() : v.toMap());
 
 export function hydrateStore(rawState) {
   const state = convertState(rawState);
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
new file mode 100644
index 000000000..7802694a3
--- /dev/null
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -0,0 +1,94 @@
+import createStream from '../stream';
+import {
+  updateTimeline,
+  deleteFromTimelines,
+  refreshHomeTimeline,
+  connectTimeline,
+  disconnectTimeline,
+} from './timelines';
+import { updateNotifications, refreshNotifications } from './notifications';
+import { getLocale } from '../locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
+  return (dispatch, getState) => {
+    const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
+    const accessToken = getState().getIn(['meta', 'access_token']);
+    const locale = getState().getIn(['meta', 'locale']);
+    let polling = null;
+
+    const setupPolling = () => {
+      polling = setInterval(() => {
+        pollingRefresh(dispatch);
+      }, 20000);
+    };
+
+    const clearPolling = () => {
+      if (polling) {
+        clearInterval(polling);
+        polling = null;
+      }
+    };
+
+    const subscription = createStream(streamingAPIBaseURL, accessToken, path, {
+
+      connected () {
+        if (pollingRefresh) {
+          clearPolling();
+        }
+        dispatch(connectTimeline(timelineId));
+      },
+
+      disconnected () {
+        if (pollingRefresh) {
+          setupPolling();
+        }
+        dispatch(disconnectTimeline(timelineId));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+          break;
+        }
+      },
+
+      reconnected () {
+        if (pollingRefresh) {
+          clearPolling();
+          pollingRefresh(dispatch);
+        }
+        dispatch(connectTimeline(timelineId));
+      },
+
+    });
+
+    const disconnect = () => {
+      if (subscription) {
+        subscription.close();
+      }
+      clearPolling();
+    };
+
+    return disconnect;
+  };
+}
+
+function refreshHomeTimelineAndNotification (dispatch) {
+  dispatch(refreshHomeTimeline());
+  dispatch(refreshNotifications());
+}
+
+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}`);
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index b6ca0661f..7cdb8c672 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -14,6 +14,8 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' },
+  unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' },
 });
 
 @injectIntl
@@ -21,11 +23,12 @@ export default class Account extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
+    hidden: PropTypes.bool,
   };
 
   handleFollow = () => {
@@ -40,13 +43,30 @@ export default class Account extends ImmutablePureComponent {
     this.props.onMute(this.props.account);
   }
 
+  handleMuteNotifications = () => {
+    this.props.onMuteNotifications(this.props.account, true);
+  }
+
+  handleUnmuteNotifications = () => {
+    this.props.onMuteNotifications(this.props.account, false);
+  }
+
   render () {
-    const { account, me, intl } = this.props;
+    const { account, me, intl, hidden } = this.props;
 
     if (!account) {
       return <div />;
     }
 
+    if (hidden) {
+      return (
+        <div>
+          {account.get('display_name')}
+          {account.get('username')}
+        </div>
+      );
+    }
+
     let buttons;
 
     if (account.get('id') !== me && account.get('relationship', null) !== null) {
@@ -60,7 +80,18 @@ export default class Account extends ImmutablePureComponent {
       } else if (blocking) {
         buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
       } else if (muting) {
-        buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
+        let hidingNotificationsButton;
+        if (muting.get('notifications')) {
+          hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username')  })} onClick={this.handleUnmuteNotifications} />;
+        } else {
+          hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username')  })} onClick={this.handleMuteNotifications} />;
+        }
+        buttons = (
+          <div>
+            <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
+            {hidingNotificationsButton}
+          </div>
+        );
       } else {
         buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
       }
@@ -70,7 +101,7 @@ export default class Account extends ImmutablePureComponent {
       <div className='account'>
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
-            <div className='account__avatar-wrapper'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             <DisplayName account={account} />
           </Permalink>
 
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js
new file mode 100644
index 000000000..e2866e8e4
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_emoji.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { unicodeMapping } from '../emojione_light';
+
+const assetHost = process.env.CDN_HOST || '';
+
+export default class AutosuggestEmoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { emoji } = this.props;
+    let url;
+
+    if (emoji.custom) {
+      url = emoji.imageUrl;
+    } else {
+      const [ filename ] = unicodeMapping[emoji.native];
+      url = `${assetHost}/emoji/${filename}.svg`;
+    }
+
+    return (
+      <div className='autosuggest-emoji'>
+        <img
+          className='emojione'
+          src={url}
+          alt={emoji.native || emoji.colons}
+        />
+
+        {emoji.colons}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index 35b37600f..6f725885d 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -1,10 +1,12 @@
 import React from 'react';
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { isRtl } from '../rtl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Textarea from 'react-textarea-autosize';
+import classNames from 'classnames';
 
 const textAtCursorMatchesToken = (str, caretPosition) => {
   let word;
@@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
     word = str.slice(left, right + caretPosition);
   }
 
-  if (!word || word.trim().length < 2 || word[0] !== '@') {
+  if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
     return [null, null];
   }
 
-  word = word.trim().toLowerCase().slice(1);
+  word = word.trim().toLowerCase();
 
   if (word.length > 0) {
     return [left + 1, word];
@@ -128,7 +130,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   }
 
   onSuggestionClick = (e) => {
-    const suggestion = Number(e.currentTarget.getAttribute('data-index'));
+    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
     e.preventDefault();
     this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
     this.textarea.focus();
@@ -151,9 +153,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
     }
   }
 
+  renderSuggestion = (suggestion, i) => {
+    const { selectedSuggestion } = this.state;
+    let inner, key;
+
+    if (typeof suggestion === 'object') {
+      inner = <AutosuggestEmoji emoji={suggestion} />;
+      key   = suggestion.id;
+    } else {
+      inner = <AutosuggestAccountContainer id={suggestion} />;
+      key   = suggestion;
+    }
+
+    return (
+      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
+        {inner}
+      </div>
+    );
+  }
+
   render () {
     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
-    const { suggestionsHidden, selectedSuggestion } = this.state;
+    const { suggestionsHidden } = this.state;
     const style = { direction: 'ltr' };
 
     if (isRtl(value)) {
@@ -164,6 +185,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
       <div className='autosuggest-textarea'>
         <label>
           <span style={{ display: 'none' }}>{placeholder}</span>
+
           <Textarea
             inputRef={this.setTextarea}
             className='autosuggest-textarea__textarea'
@@ -181,18 +203,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
         </label>
 
         <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
-          {suggestions.map((suggestion, i) => (
-            <div
-              role='button'
-              tabIndex='0'
-              key={suggestion}
-              data-index={suggestion}
-              className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
-              onMouseDown={this.onSuggestionClick}
-            >
-              <AutosuggestAccountContainer id={suggestion} />
-            </div>
-          ))}
+          {suggestions.map(this.renderSuggestion)}
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js
index 4f8170657..dd155f059 100644
--- a/app/javascript/mastodon/components/avatar.js
+++ b/app/javascript/mastodon/components/avatar.js
@@ -1,11 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 
 export default class Avatar extends React.PureComponent {
 
   static propTypes = {
-    src: PropTypes.string.isRequired,
-    staticSrc: PropTypes.string,
+    account: ImmutablePropTypes.map.isRequired,
     size: PropTypes.number.isRequired,
     style: PropTypes.object,
     animate: PropTypes.bool,
@@ -33,9 +33,12 @@ export default class Avatar extends React.PureComponent {
   }
 
   render () {
-    const { src, size, staticSrc, animate, inline } = this.props;
+    const { account, size, animate, inline } = this.props;
     const { hovering } = this.state;
 
+    const src = account.get('avatar');
+    const staticSrc = account.get('avatar_static');
+
     let className = 'account__avatar';
 
     if (inline) {
@@ -61,6 +64,7 @@ export default class Avatar extends React.PureComponent {
         onMouseEnter={this.handleMouseEnter}
         onMouseLeave={this.handleMouseLeave}
         style={style}
+        data-avatar-of={`@${account.get('acct')}`}
       />
     );
   }
diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js
index de43e0ef5..2ecf9fa44 100644
--- a/app/javascript/mastodon/components/avatar_overlay.js
+++ b/app/javascript/mastodon/components/avatar_overlay.js
@@ -1,28 +1,28 @@
 import React from 'react';
-import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 
 export default class AvatarOverlay extends React.PureComponent {
 
   static propTypes = {
-    staticSrc: PropTypes.string.isRequired,
-    overlaySrc: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    friend: ImmutablePropTypes.map.isRequired,
   };
 
   render() {
-    const { staticSrc, overlaySrc } = this.props;
+    const { account, friend } = this.props;
 
     const baseStyle = {
-      backgroundImage: `url(${staticSrc})`,
+      backgroundImage: `url(${account.get('avatar_static')})`,
     };
 
     const overlayStyle = {
-      backgroundImage: `url(${overlaySrc})`,
+      backgroundImage: `url(${friend.get('avatar_static')})`,
     };
 
     return (
       <div className='account__avatar-overlay'>
-        <div className='account__avatar-overlay-base' style={baseStyle} />
-        <div className='account__avatar-overlay-overlay' style={overlayStyle} />
+        <div className='account__avatar-overlay-base' style={baseStyle} data-avatar-of={`@${account.get('acct')}`} />
+        <div className='account__avatar-overlay-overlay' style={overlayStyle} data-avatar-of={`@${friend.get('acct')}`} />
       </div>
     );
   }
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
index c7a931562..2e1467595 100644
--- a/app/javascript/mastodon/components/column.js
+++ b/app/javascript/mastodon/components/column.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import detectPassiveEvents from 'detect-passive-events';
-import scrollTop from '../scroll';
+import { scrollTop } from '../scroll';
 
 export default class Column extends React.PureComponent {
 
@@ -34,7 +34,7 @@ export default class Column extends React.PureComponent {
   }
 
   componentDidMount () {
-    this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents ? { passive: true } : false);
+    this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
   }
 
   componentWillUnmount () {
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index dc3665a2b..2cf84f8f4 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -1,7 +1,5 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
-import emojify from '../emoji';
 
 export default class DisplayName extends React.PureComponent {
 
@@ -10,12 +8,11 @@ export default class DisplayName extends React.PureComponent {
   };
 
   render () {
-    const displayName     = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
-    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const displayNameHtml = { __html: this.props.account.get('display_name_html') };
 
     return (
       <span className='display-name'>
-        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
+        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
       </span>
     );
   }
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 28631f463..c0fbcab6d 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -1,53 +1,58 @@
 import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from './icon_button';
+import { Overlay } from 'react-overlays';
+import { Motion, spring } from 'react-motion';
+import detectPassiveEvents from 'detect-passive-events';
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
-export default class DropdownMenu extends React.PureComponent {
+class DropdownMenu extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
   };
 
   static propTypes = {
-    isUserTouching: PropTypes.func,
-    isModalOpen: PropTypes.bool.isRequired,
-    onModalOpen: PropTypes.func,
-    onModalClose: PropTypes.func,
-    icon: PropTypes.string.isRequired,
     items: PropTypes.array.isRequired,
-    size: PropTypes.number.isRequired,
-    direction: PropTypes.string,
-    status: ImmutablePropTypes.map,
-    ariaLabel: PropTypes.string,
-    disabled: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    placement: PropTypes.string,
+    arrowOffsetLeft: PropTypes.string,
+    arrowOffsetTop: PropTypes.string,
   };
 
   static defaultProps = {
-    ariaLabel: 'Menu',
-    isModalOpen: false,
-    isUserTouching: () => false,
+    style: {},
+    placement: 'bottom',
   };
 
-  state = {
-    direction: 'left',
-    expanded: false,
-  };
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
 
-  setRef = (c) => {
-    this.dropdown = c;
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
   }
 
-  handleClick = (e) => {
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  handleClick = e => {
     const i = Number(e.currentTarget.getAttribute('data-index'));
     const { action, to } = this.props.items[i];
 
-    if (this.props.isModalOpen) {
-      this.props.onModalClose();
-    }
-
-    // Don't call e.preventDefault() when the item uses 'href' property.
-    // ex. "Edit profile" on the account action bar
+    this.props.onClose();
 
     if (typeof action === 'function') {
       e.preventDefault();
@@ -56,90 +61,149 @@ export default class DropdownMenu extends React.PureComponent {
       e.preventDefault();
       this.context.router.history.push(to);
     }
+  }
+
+  renderItem (option, i) {
+    if (option === null) {
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
+    }
+
+    const { text, href = '#' } = option;
+
+    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}>
+          {text}
+        </a>
+      </li>
+    );
+  }
 
-    this.dropdown.hide();
+  render () {
+    const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
+            <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
+
+            <ul>
+              {items.map((option, i) => this.renderItem(option, i))}
+            </ul>
+          </div>
+        )}
+      </Motion>
+    );
   }
 
-  handleShow = () => {
-    if (this.props.isUserTouching()) {
+}
+
+export default class Dropdown extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    icon: PropTypes.string.isRequired,
+    items: PropTypes.array.isRequired,
+    size: PropTypes.number.isRequired,
+    ariaLabel: PropTypes.string,
+    disabled: PropTypes.bool,
+    status: ImmutablePropTypes.map,
+    isUserTouching: PropTypes.func,
+    isModalOpen: PropTypes.bool.isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+  };
+
+  static defaultProps = {
+    ariaLabel: 'Menu',
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  handleClick = () => {
+    if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
+      const { status, items } = this.props;
+
       this.props.onModalOpen({
-        status: this.props.status,
-        actions: this.props.items,
-        onClick: this.handleClick,
+        status,
+        actions: items,
+        onClick: this.handleItemClick,
       });
-    } else {
-      this.setState({ expanded: true });
+
+      return;
     }
+
+    this.setState({ expanded: !this.state.expanded });
   }
 
-  handleHide = () => this.setState({ expanded: false })
-
-  handleToggle = (e) => {
-    if (e.key === 'Enter') {
-      if (this.props.isUserTouching()) {
-        this.handleShow();
-      } else {
-        this.setState({ expanded: !this.state.expanded });
-      }
-    } else if (e.key === 'Escape') {
-      this.setState({ expanded: false });
+  handleClose = () => {
+    if (this.props.onModalClose) {
+      this.props.onModalClose();
     }
+
+    this.setState({ expanded: false });
   }
 
-  renderItem = (item, i) => {
-    if (item === null) {
-      return <li key={`sep-${i}`} className='dropdown__sep' />;
+  handleKeyDown = e => {
+    switch(e.key) {
+    case 'Enter':
+      this.handleClick();
+      break;
+    case 'Escape':
+      this.handleClose();
+      break;
     }
+  }
 
-    const { text, href = '#' } = item;
+  handleItemClick = e => {
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+    const { action, to } = this.props.items[i];
 
-    return (
-      <li className='dropdown__content-list-item' key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'>
-          {text}
-        </a>
-      </li>
-    );
-  }
+    this.handleClose();
 
-  render () {
-    const { icon, items, size, direction, ariaLabel, disabled } = this.props;
-    const { expanded }   = this.state;
-    const isUserTouching = this.props.isUserTouching();
-    const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
-    const iconStyle      = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
-    const iconClassname  = `fa fa-fw fa-${icon} dropdown__icon`;
-
-    if (disabled) {
-      return (
-        <div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
-          <i className={iconClassname} aria-hidden />
-        </div>
-      );
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+    } else if (to) {
+      e.preventDefault();
+      this.context.router.history.push(to);
     }
+  }
 
-    const dropdownItems = expanded && (
-      <ul role='group' className='dropdown__content-list' onClick={this.handleHide}>
-        {items.map(this.renderItem)}
-      </ul>
-    );
+  setTargetRef = c => {
+    this.target = c;
+  }
+
+  findTarget = () => {
+    return this.target;
+  }
 
-    // No need to render the actual dropdown if we use the modal. If we
-    // don't render anything <Dropdow /> breaks, so we just put an empty div.
-    const dropdownContent = !isUserTouching ? (
-      <DropdownContent className={directionClass} >
-        {dropdownItems}
-      </DropdownContent>
-    ) : <div />;
+  render () {
+    const { icon, items, size, ariaLabel, disabled } = this.props;
+    const { expanded } = this.state;
 
     return (
-      <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}>
-        <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}>
-          <i className={iconClassname} aria-hidden />
-        </DropdownTrigger>
-
-        {dropdownContent}
-      </Dropdown>
+      <div onKeyDown={this.handleKeyDown}>
+        <IconButton
+          icon={icon}
+          title={ariaLabel}
+          active={expanded}
+          disabled={disabled}
+          size={size}
+          ref={this.setTargetRef}
+          onClick={this.handleClick}
+        />
+
+        <Overlay show={expanded} placement='bottom' target={this.findTarget}>
+          <DropdownMenu items={items} onClose={this.handleClose} />
+        </Overlay>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 8c5b5e0b9..ca4b14b82 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -73,8 +73,23 @@ export default class IconButton extends React.PureComponent {
       classes.push(this.props.className);
     }
 
+    const flipDeg = this.props.flip ? -180 : -360;
+    const rotateDeg = this.props.active ? flipDeg : 0;
+
+    const motionDefaultStyle = {
+      rotate: rotateDeg,
+    };
+
+    const springOpts = {
+      stiffness: this.props.flip ? 60 : 120,
+      damping: 7,
+    };
+    const motionStyle = {
+      rotate: this.props.animate ? spring(rotateDeg, springOpts) : 0,
+    };
+
     return (
-      <Motion defaultStyle={{ rotate: this.props.active ? (this.props.flip ? -180 : -360) : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? (this.props.flip ? -180 : -360) : 0, { stiffness: this.props.flip ? 60 : 120, damping: 7 }) : 0 }}>
+      <Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
         {({ rotate }) =>
           <button
             aria-label={this.props.title}
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
new file mode 100644
index 000000000..575743350
--- /dev/null
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
+import { is } from 'immutable';
+
+// Diff these props in the "rendered" state
+const updateOnPropsForRendered = ['id', 'index', 'listLength'];
+// Diff these props in the "unrendered" state
+const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
+
+export default class IntersectionObserverArticle extends React.Component {
+
+  static propTypes = {
+    intersectionObserverWrapper: PropTypes.object.isRequired,
+    id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    saveHeightKey: PropTypes.string,
+    cachedHeight: PropTypes.number,
+    onHeightChange: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  state = {
+    isHidden: false, // set to true in requestIdleCallback to trigger un-render
+  }
+
+  shouldComponentUpdate (nextProps, nextState) {
+    const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
+    const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
+    if (!!isUnrendered !== !!willBeUnrendered) {
+      // If we're going from rendered to unrendered (or vice versa) then update
+      return true;
+    }
+    // Otherwise, diff based on props
+    const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
+    return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
+  }
+
+  componentDidMount () {
+    const { intersectionObserverWrapper, id } = this.props;
+
+    intersectionObserverWrapper.observe(
+      id,
+      this.node,
+      this.handleIntersection
+    );
+
+    this.componentMounted = true;
+  }
+
+  componentWillUnmount () {
+    const { intersectionObserverWrapper, id } = this.props;
+    intersectionObserverWrapper.unobserve(id, this.node);
+
+    this.componentMounted = false;
+  }
+
+  handleIntersection = (entry) => {
+    const { onHeightChange, saveHeightKey, id } = this.props;
+
+    if (this.node && this.node.children.length !== 0) {
+      // save the height of the fully-rendered element
+      this.height = getRectFromEntry(entry).height;
+
+      if (onHeightChange && saveHeightKey) {
+        onHeightChange(saveHeightKey, id, this.height);
+      }
+    }
+
+    this.setState((prevState) => {
+      if (prevState.isIntersecting && !entry.isIntersecting) {
+        scheduleIdleTask(this.hideIfNotIntersecting);
+      }
+      return {
+        isIntersecting: entry.isIntersecting,
+        isHidden: false,
+      };
+    });
+  }
+
+  hideIfNotIntersecting = () => {
+    if (!this.componentMounted) {
+      return;
+    }
+
+    // When the browser gets a chance, test if we're still not intersecting,
+    // and if so, set our isHidden to true to trigger an unrender. The point of
+    // this is to save DOM nodes and avoid using up too much memory.
+    // See: https://github.com/tootsuite/mastodon/issues/2900
+    this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+  }
+
+  handleRef = (node) => {
+    this.node = node;
+  }
+
+  render () {
+    const { children, id, index, listLength, cachedHeight } = this.props;
+    const { isIntersecting, isHidden } = this.state;
+
+    if (!isIntersecting && (isHidden || cachedHeight)) {
+      return (
+        <article
+          ref={this.handleRef}
+          aria-posinset={index}
+          aria-setsize={listLength}
+          style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
+          data-id={id}
+          tabIndex='0'
+        >
+          {children && React.cloneElement(children, { hidden: true })}
+        </article>
+      );
+    }
+
+    return (
+      <article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'>
+        {children && React.cloneElement(children, { hidden: false })}
+      </article>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js
index e2fe1fed7..c4c8c94a2 100644
--- a/app/javascript/mastodon/components/load_more.js
+++ b/app/javascript/mastodon/components/load_more.js
@@ -17,7 +17,7 @@ export default class LoadMore extends React.PureComponent {
     const { visible } = this.props;
 
     return (
-      <button className='load-more' disabled={!visible} style={{ opacity: visible ? 1 : 0 }} onClick={this.props.onClick}>
+      <button className='load-more' 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/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index fa6ea72d5..8bc1427d9 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -4,9 +4,12 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
+import { is } from 'immutable';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { isIOS } from '../is_mobile';
+import classNames from 'classnames';
+import sizeMe from 'react-sizeme';
 
 const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@@ -20,6 +23,7 @@ class Item extends React.PureComponent {
 
   static propTypes = {
     attachment: ImmutablePropTypes.map.isRequired,
+    standalone: PropTypes.bool,
     index: PropTypes.number.isRequired,
     size: PropTypes.number.isRequired,
     onClick: PropTypes.func.isRequired,
@@ -28,6 +32,9 @@ class Item extends React.PureComponent {
 
   static defaultProps = {
     autoPlayGif: false,
+    standalone: false,
+    index: 0,
+    size: 1,
   };
 
   handleMouseEnter = (e) => {
@@ -60,7 +67,7 @@ class Item extends React.PureComponent {
   }
 
   render () {
-    const { attachment, index, size } = this.props;
+    const { attachment, index, size, standalone } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -122,8 +129,8 @@ class Item extends React.PureComponent {
 
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
-      const srcSet = hasSize && `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
-      const sizes = hasSize && `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
+      const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
+      const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
 
       thumbnail = (
         <a
@@ -139,7 +146,7 @@ class Item extends React.PureComponent {
       const autoPlay = !isIOS() && this.props.autoPlayGif;
 
       thumbnail = (
-        <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
+        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
           <video
             className='media-gallery__item-gifv-thumbnail'
             role='application'
@@ -158,7 +165,7 @@ class Item extends React.PureComponent {
     }
 
     return (
-      <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+      <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
         {thumbnail}
       </div>
     );
@@ -167,11 +174,14 @@ class Item extends React.PureComponent {
 }
 
 @injectIntl
+@sizeMe({})
 export default class MediaGallery extends React.PureComponent {
 
   static propTypes = {
     sensitive: PropTypes.bool,
+    standalone: PropTypes.bool,
     media: ImmutablePropTypes.list.isRequired,
+    size: PropTypes.object,
     height: PropTypes.number.isRequired,
     onOpenMedia: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -180,6 +190,7 @@ export default class MediaGallery extends React.PureComponent {
 
   static defaultProps = {
     autoPlayGif: false,
+    standalone: false,
   };
 
   state = {
@@ -187,7 +198,7 @@ export default class MediaGallery extends React.PureComponent {
   };
 
   componentWillReceiveProps (nextProps) {
-    if (nextProps.sensitive !== this.props.sensitive) {
+    if (!is(nextProps.media, this.props.media)) {
       this.setState({ visible: !nextProps.sensitive });
     }
   }
@@ -201,10 +212,19 @@ export default class MediaGallery extends React.PureComponent {
   }
 
   render () {
-    const { media, intl, sensitive } = this.props;
+    const { media, intl, sensitive, height, standalone, size } = this.props;
 
     let children;
 
+    const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+    const style = {};
+
+    if (standaloneEligible) {
+      style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']);
+    } else {
+      style.height = height;
+    }
+
     if (!this.state.visible) {
       let warning;
 
@@ -215,19 +235,24 @@ export default class MediaGallery extends React.PureComponent {
       }
 
       children = (
-        <button className='media-spoiler' onClick={this.handleOpen}>
+        <button className='media-spoiler' onClick={this.handleOpen} style={style}>
           <span className='media-spoiler__warning'>{warning}</span>
           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
         </button>
       );
     } else {
       const size = media.take(4).size;
-      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
+
+      if (standaloneEligible) {
+        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
+      } else {
+        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
+      }
     }
 
     return (
-      <div className='media-gallery' style={{ height: `${this.props.height}px` }}>
-        <div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
+      <div className='media-gallery' style={style}>
+        <div className={classNames('spoiler-button', { 'spoiler-button--visible': this.state.visible })}>
           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
         </div>
 
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
new file mode 100644
index 000000000..ff0540e5d
--- /dev/null
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -0,0 +1,215 @@
+import React, { PureComponent } from 'react';
+import { ScrollContainer } from 'react-router-scroll';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
+import LoadMore from './load_more';
+import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+import { List as ImmutableList } from 'immutable';
+
+export default class ScrollableList extends PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    scrollKey: PropTypes.string.isRequired,
+    onScrollToBottom: PropTypes.func,
+    onScrollToTop: PropTypes.func,
+    onScroll: PropTypes.func,
+    trackScroll: PropTypes.bool,
+    shouldUpdateScroll: PropTypes.func,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    prepend: PropTypes.node,
+    emptyMessage: PropTypes.node,
+    children: PropTypes.node,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  state = {
+    lastMouseMove: null,
+  };
+
+  intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+  handleScroll = throttle(() => {
+    if (this.node) {
+      const { scrollTop, scrollHeight, clientHeight } = this.node;
+      const offset = scrollHeight - scrollTop - clientHeight;
+      this._oldScrollPosition = scrollHeight - scrollTop;
+
+      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+        this.props.onScrollToBottom();
+      } else if (scrollTop < 100 && this.props.onScrollToTop) {
+        this.props.onScrollToTop();
+      } else if (this.props.onScroll) {
+        this.props.onScroll();
+      }
+    }
+  }, 150, {
+    trailing: true,
+  });
+
+  handleMouseMove = throttle(() => {
+    this._lastMouseMove = new Date();
+  }, 300);
+
+  handleMouseLeave = () => {
+    this._lastMouseMove = null;
+  }
+
+  componentDidMount () {
+    this.attachScrollListener();
+    this.attachIntersectionObserver();
+
+    // Handle initial scroll posiiton
+    this.handleScroll();
+  }
+
+  componentDidUpdate (prevProps) {
+    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);
+
+    // Reset the scroll position when a new child comes in in order not to
+    // jerk the scrollbar around if you're already scrolled down the page.
+    if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
+      const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
+
+      if (this.node.scrollTop !== newScrollTop) {
+        this.node.scrollTop = newScrollTop;
+      }
+    } else {
+      this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
+    }
+  }
+
+  componentWillUnmount () {
+    this.detachScrollListener();
+    this.detachIntersectionObserver();
+  }
+
+  attachIntersectionObserver () {
+    this.intersectionObserverWrapper.connect({
+      root: this.node,
+      rootMargin: '300% 0px',
+    });
+  }
+
+  detachIntersectionObserver () {
+    this.intersectionObserverWrapper.disconnect();
+  }
+
+  attachScrollListener () {
+    this.node.addEventListener('scroll', this.handleScroll);
+  }
+
+  detachScrollListener () {
+    this.node.removeEventListener('scroll', this.handleScroll);
+  }
+
+  getFirstChildKey (props) {
+    const { children } = props;
+    let firstChild = children;
+    if (children instanceof ImmutableList) {
+      firstChild = children.get(0);
+    } else if (Array.isArray(children)) {
+      firstChild = children[0];
+    }
+    return firstChild && firstChild.key;
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.props.onScrollToBottom();
+  }
+
+  _recentlyMoved () {
+    return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
+  }
+
+  handleKeyDown = (e) => {
+    if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
+      const article = (() => {
+        switch (e.key) {
+        case 'PageDown':
+          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
+        case 'PageUp':
+          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
+        case 'End':
+          return this.node.querySelector('[role="feed"] > article:last-of-type');
+        case 'Home':
+          return this.node.querySelector('[role="feed"] > article:first-of-type');
+        default:
+          return null;
+        }
+      })();
+
+
+      if (article) {
+        e.preventDefault();
+        article.focus();
+        article.scrollIntoView();
+      }
+    }
+  }
+
+  render () {
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const childrenCount = React.Children.count(children);
+
+    const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
+    let scrollableArea = null;
+
+    if (isLoading || childrenCount > 0 || !emptyMessage) {
+      scrollableArea = (
+        <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
+          <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
+            {prepend}
+
+            {React.Children.map(this.props.children, (child, index) => (
+              <IntersectionObserverArticleContainer
+                key={child.key}
+                id={child.key}
+                index={index}
+                listLength={childrenCount}
+                intersectionObserverWrapper={this.intersectionObserverWrapper}
+                saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
+              >
+                {child}
+              </IntersectionObserverArticleContainer>
+            ))}
+
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          {emptyMessage}
+        </div>
+      );
+    }
+
+    if (trackScroll) {
+      return (
+        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+          {scrollableArea}
+        </ScrollContainer>
+      );
+    } else {
+      return scrollableArea;
+    }
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index ac82e536f..9e65db85c 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -11,16 +11,12 @@ import DisplayName from './display_name';
 import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
-import emojify from '../emoji';
-import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
-import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
+import { MediaGallery, Video } from '../features/ui/util/async-components';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
 import Bundle from '../features/ui/components/bundle';
-import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
 
 export default class Status extends ImmutablePureComponent {
 
@@ -31,27 +27,25 @@ export default class Status extends ImmutablePureComponent {
   static propTypes = {
     status: ImmutablePropTypes.map,
     account: ImmutablePropTypes.map,
-    wrapped: PropTypes.bool,
     onReply: PropTypes.func,
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onPin: PropTypes.func,
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
     onBlock: PropTypes.func,
-    me: PropTypes.number,
+    onEmbed: PropTypes.func,
+    onHeightChange: PropTypes.func,
+    me: PropTypes.string,
     boostModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
     muted: PropTypes.bool,
-    intersectionObserverWrapper: PropTypes.object,
-    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
-    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    hidden: PropTypes.bool,
   };
 
   state = {
     isExpanded: false,
-    isIntersecting: true, // assume intersecting until told otherwise
-    isHidden: false, // set to true in requestIdleCallback to trigger un-render
   }
 
   // Avoid checking props that are functions (and whose equality will always
@@ -59,87 +53,15 @@ export default class Status extends ImmutablePureComponent {
   updateOnProps = [
     'status',
     'account',
-    'wrapped',
     'me',
     'boostModal',
     'autoPlayGif',
     'muted',
-    'listLength',
+    'hidden',
   ]
 
   updateOnStates = ['isExpanded']
 
-  shouldComponentUpdate (nextProps, nextState) {
-    if (!nextState.isIntersecting && nextState.isHidden) {
-      // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
-      // that either "isIntersecting" or "isHidden" matter, and then they're
-      // the only things that matter (and updated ARIA attributes).
-      return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
-    } else if (nextState.isIntersecting && !this.state.isIntersecting) {
-      // If we're going from a non-intersecting state to an intersecting state,
-      // (i.e. offscreen to onscreen), then we definitely need to re-render
-      return true;
-    }
-    // Otherwise, diff based on "updateOnProps" and "updateOnStates"
-    return super.shouldComponentUpdate(nextProps, nextState);
-  }
-
-  componentDidMount () {
-    if (!this.props.intersectionObserverWrapper) {
-      // TODO: enable IntersectionObserver optimization for notification statuses.
-      // These are managed in notifications/index.js rather than status_list.js
-      return;
-    }
-    this.props.intersectionObserverWrapper.observe(
-      this.props.id,
-      this.node,
-      this.handleIntersection
-    );
-
-    this.componentMounted = true;
-  }
-
-  componentWillUnmount () {
-    if (this.props.intersectionObserverWrapper) {
-      this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
-    }
-
-    this.componentMounted = false;
-  }
-
-  handleIntersection = (entry) => {
-    if (this.node && this.node.children.length !== 0) {
-      // save the height of the fully-rendered element
-      this.height = getRectFromEntry(entry).height;
-    }
-
-    this.setState((prevState) => {
-      if (prevState.isIntersecting && !entry.isIntersecting) {
-        scheduleIdleTask(this.hideIfNotIntersecting);
-      }
-      return {
-        isIntersecting: entry.isIntersecting,
-        isHidden: false,
-      };
-    });
-  }
-
-  hideIfNotIntersecting = () => {
-    if (!this.componentMounted) {
-      return;
-    }
-
-    // When the browser gets a chance, test if we're still not intersecting,
-    // and if so, set our isHidden to true to trigger an unrender. The point of
-    // this is to save DOM nodes and avoid using up too much memory.
-    // See: https://github.com/tootsuite/mastodon/issues/2900
-    this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
-  }
-
-  handleRef = (node) => {
-    this.node = node;
-  }
-
   handleClick = () => {
     if (!this.context.router) {
       return;
@@ -151,7 +73,7 @@ export default class Status extends ImmutablePureComponent {
 
   handleAccountClick = (e) => {
     if (this.context.router && e.button === 0) {
-      const id = Number(e.currentTarget.getAttribute('data-id'));
+      const id = e.currentTarget.getAttribute('data-id');
       e.preventDefault();
       this.context.router.history.push(`/accounts/${id}`);
     }
@@ -169,46 +91,42 @@ export default class Status extends ImmutablePureComponent {
     return <div className='media-spoiler-video' style={{ height: '110px' }} />;
   }
 
+  handleOpenVideo = startTime => {
+    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  }
+
   render () {
     let media = null;
     let statusAvatar;
 
-    // Exclude intersectionObserverWrapper from `other` variable
-    // because intersection is managed in here.
-    const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props;
-    const { isExpanded, isIntersecting, isHidden } = this.state;
+    const { status, account, hidden, ...other } = this.props;
+    const { isExpanded } = this.state;
 
     if (status === null) {
       return null;
     }
 
-    if (!isIntersecting && isHidden) {
+    if (hidden) {
       return (
-        <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
+        <div>
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
           {status.get('content')}
-        </article>
+        </div>
       );
     }
 
     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
-      let displayName = status.getIn(['account', 'display_name']);
-
-      if (displayName.length === 0) {
-        displayName = status.getIn(['account', 'username']);
-      }
-
-      const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+      const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
 
       return (
-        <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'>
+        <div className='status__wrapper' data-id={status.get('id')} >
           <div className='status__prepend'>
             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
-            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
+            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
           </div>
 
-          <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} />
-        </article>
+          <Status {...other} status={status.get('reblog')} account={status.get('account')} />
+        </div>
       );
     }
 
@@ -216,9 +134,18 @@ export default class Status extends ImmutablePureComponent {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
 
       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        const video = status.getIn(['media_attachments', 0]);
+
         media = (
-          <Bundle fetchComponent={VideoPlayer} loading={this.renderLoadingVideoPlayer} >
-            {Component => <Component media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />}
+          <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
+            {Component => <Component
+              preview={video.get('preview_url')}
+              src={video.get('url')}
+              width={239}
+              height={110}
+              sensitive={status.get('sensitive')}
+              onOpenVideo={this.handleOpenVideo}
+            />}
           </Bundle>
         );
       } else {
@@ -231,13 +158,13 @@ export default class Status extends ImmutablePureComponent {
     }
 
     if (account === undefined || account === null) {
-      statusAvatar = <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />;
+      statusAvatar = <Avatar account={status.get('account')} size={48} />;
     }else{
-      statusAvatar = <AvatarOverlay staticSrc={status.getIn(['account', 'avatar_static'])} overlaySrc={account.get('avatar_static')} />;
+      statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
     }
 
     return (
-      <article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} tabIndex={wrapped ? null : '0'}  ref={this.handleRef}>
+      <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
         <div className='status__info'>
           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
 
@@ -255,7 +182,7 @@ export default class Status extends ImmutablePureComponent {
         {media}
 
         <StatusActionBar {...this.props} />
-      </article>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 81c2a4e23..cf9c8fb53 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -24,6 +24,9 @@ const messages = defineMessages({
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
 });
 
 @injectIntl
@@ -43,8 +46,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onMute: PropTypes.func,
     onBlock: PropTypes.func,
     onReport: PropTypes.func,
+    onEmbed: PropTypes.func,
     onMuteConversation: PropTypes.func,
-    me: PropTypes.number,
+    onPin: PropTypes.func,
+    me: PropTypes.string,
     withDismiss: PropTypes.bool,
     intl: PropTypes.object.isRequired,
   };
@@ -80,6 +85,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onDelete(this.props.status);
   }
 
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
   handleMentionClick = () => {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
@@ -96,6 +105,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
   }
 
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
   handleReport = () => {
     this.props.onReport(this.props.status);
   }
@@ -106,9 +119,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
 
   render () {
     const { status, me, intl, withDismiss } = this.props;
-    const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
+
     const mutingConversation = status.get('muted');
-    const anonymousAccess = !me;
+    const anonymousAccess    = !me;
+    const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
 
     let menu = [];
     let reblogIcon = 'retweet';
@@ -116,14 +130,23 @@ export default class StatusActionBar extends ImmutablePureComponent {
     let replyTitle;
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
     menu.push(null);
 
-    if (withDismiss) {
+    if (status.getIn(['account', 'id']) === me || withDismiss) {
       menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
       menu.push(null);
     }
 
     if (status.getIn(['account', 'id']) === me) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
       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 });
@@ -154,7 +177,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
-        <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
         {shareButton}
 
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 5f02e3261..d1381f176 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -3,9 +3,7 @@
 
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
 import PropTypes from 'prop-types';
-import emojify from '../emoji';
 import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
@@ -122,8 +120,8 @@ export default class StatusContent extends React.PureComponent {
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
 
-    const content = { __html: emojify(status.get('content')) };
-    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const content = { __html: status.get('contentHtml') };
+    const spoilerContent = { __html: status.get('spoilerHtml') };
     const directionStyle = { direction: 'ltr' };
     const classNames = classnames('status__content', {
       'status__content--with-action': this.props.onClick && this.context.router,
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 639c8b4e7..9026ebb0c 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -1,12 +1,9 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { ScrollContainer } from 'react-router-scroll';
 import PropTypes from 'prop-types';
 import StatusContainer from '../../glitch/components/status/container';
-import LoadMore from './load_more';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
-import { throttle } from 'lodash';
+import ScrollableList from './scrollable_list';
 
 export default class StatusList extends ImmutablePureComponent {
 
@@ -28,145 +25,21 @@ export default class StatusList extends ImmutablePureComponent {
     trackScroll: true,
   };
 
-  intersectionObserverWrapper = new IntersectionObserverWrapper();
-
-  handleScroll = throttle(() => {
-    if (this.node) {
-      const { scrollTop, scrollHeight, clientHeight } = this.node;
-      const offset = scrollHeight - scrollTop - clientHeight;
-      this._oldScrollPosition = scrollHeight - scrollTop;
-
-      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
-        this.props.onScrollToBottom();
-      } else if (scrollTop < 100 && this.props.onScrollToTop) {
-        this.props.onScrollToTop();
-      } else if (this.props.onScroll) {
-        this.props.onScroll();
-      }
-    }
-  }, 150, {
-    trailing: true,
-  });
-
-  componentDidMount () {
-    this.attachScrollListener();
-    this.attachIntersectionObserver();
-
-    // Handle initial scroll posiiton
-    this.handleScroll();
-  }
-
-  componentDidUpdate (prevProps) {
-    // Reset the scroll position when a new toot comes in in order not to
-    // jerk the scrollbar around if you're already scrolled down the page.
-    if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) {
-      if (prevProps.statusIds.first() !== this.props.statusIds.first()) {
-        let newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
-        if (this.node.scrollTop !== newScrollTop) {
-          this.node.scrollTop = newScrollTop;
-        }
-      } else {
-        this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
-      }
-    }
-  }
-
-  componentWillUnmount () {
-    this.detachScrollListener();
-    this.detachIntersectionObserver();
-  }
-
-  attachIntersectionObserver () {
-    this.intersectionObserverWrapper.connect({
-      root: this.node,
-      rootMargin: '300% 0px',
-    });
-  }
-
-  detachIntersectionObserver () {
-    this.intersectionObserverWrapper.disconnect();
-  }
-
-  attachScrollListener () {
-    this.node.addEventListener('scroll', this.handleScroll);
-  }
-
-  detachScrollListener () {
-    this.node.removeEventListener('scroll', this.handleScroll);
-  }
-
-  setRef = (c) => {
-    this.node = c;
-  }
-
-  handleLoadMore = (e) => {
-    e.preventDefault();
-    this.props.onScrollToBottom();
-  }
-
-  handleKeyDown = (e) => {
-    if (['PageDown', 'PageUp', 'End', 'Home'].includes(e.key)) {
-      const article = (() => {
-        switch (e.key) {
-        case 'PageDown':
-          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
-        case 'PageUp':
-          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
-        case 'End':
-          return this.node.querySelector('[role="feed"] > article:last-of-type');
-        case 'Home':
-          return this.node.querySelector('[role="feed"] > article:first-of-type');
-        default:
-          return null;
-        }
-      })();
-
-
-      if (article) {
-        e.preventDefault();
-        article.focus();
-        article.scrollIntoView();
-      }
-    }
-  }
-
   render () {
-    const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
-
-    const loadMore     = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />;
-    let scrollableArea = null;
-
-    if (isLoading || statusIds.size > 0 || !emptyMessage) {
-      scrollableArea = (
-        <div className='scrollable' ref={this.setRef}>
-          <div role='feed' className='status-list' onKeyDown={this.handleKeyDown}>
-            {prepend}
-
-            {statusIds.map((statusId, index) => {
-              return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
-            })}
-
-            {loadMore}
-          </div>
-        </div>
-      );
-    } else {
-      scrollableArea = (
-        <div className='empty-column-indicator' ref={this.setRef}>
-          {emptyMessage}
-        </div>
-      );
-    }
-
-    if (trackScroll) {
-      return (
-        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
-          {scrollableArea}
-        </ScrollContainer>
-      );
-    } else {
-      return scrollableArea;
-    }
+    const { statusIds, ...other } = this.props;
+    const { isLoading } = other;
+
+    const scrollableContent = (isLoading || statusIds.size > 0) ? (
+      statusIds.map((statusId) => (
+        <StatusContainer key={statusId} id={statusId} />
+      ))
+    ) : null;
+
+    return (
+      <ScrollableList {...other}>
+        {scrollableContent}
+      </ScrollableList>
+    );
   }
 
 }
diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js
index 5f2447c6d..26914f113 100644
--- a/app/javascript/mastodon/components/video_player.js
+++ b/app/javascript/mastodon/components/video_player.js
@@ -149,29 +149,29 @@ export default class VideoPlayer extends React.PureComponent {
     if (!this.state.visible) {
       if (sensitive) {
         return (
-          <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
+          <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
             {spoilerButton}
             <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
+          </button>
         );
       } else {
         return (
-          <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
+          <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
             {spoilerButton}
             <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
+          </button>
         );
       }
     }
 
     if (this.state.preview && !autoplay) {
       return (
-        <div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
+        <button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
           {spoilerButton}
           <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
-        </div>
+        </button>
       );
     }
 
diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js
index ca1efd0e5..5728c878e 100644
--- a/app/javascript/mastodon/containers/account_container.js
+++ b/app/javascript/mastodon/containers/account_container.js
@@ -12,6 +12,7 @@ import {
   unmuteAccount,
 } from '../actions/accounts';
 import { openModal } from '../actions/modal';
+import { initMuteModal } from '../actions/mutes';
 
 const messages = defineMessages({
   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@@ -32,7 +33,7 @@ const makeMapStateToProps = () => {
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onFollow (account) {
-    if (account.getIn(['relationship', 'following'])) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
       if (this.unfollowModal) {
         dispatch(openModal('CONFIRM', {
           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
@@ -59,10 +60,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     if (account.getIn(['relationship', 'muting'])) {
       dispatch(unmuteAccount(account.get('id')));
     } else {
-      dispatch(muteAccount(account.get('id')));
+      dispatch(initMuteModal(account));
     }
   },
 
+
+  onMuteNotifications (account, notifications) {
+    dispatch(muteAccount(account.get('id'), notifications));
+  },
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/mastodon/containers/card_container.js b/app/javascript/mastodon/containers/card_container.js
new file mode 100644
index 000000000..11b9f88d4
--- /dev/null
+++ b/app/javascript/mastodon/containers/card_container.js
@@ -0,0 +1,18 @@
+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/compose_container.js b/app/javascript/mastodon/containers/compose_container.js
new file mode 100644
index 000000000..db452d03a
--- /dev/null
+++ b/app/javascript/mastodon/containers/compose_container.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from '../store/configureStore';
+import { hydrateStore } from '../actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import Compose from '../features/standalone/compose';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+const initialStateContainer = document.getElementById('initial-state');
+
+if (initialStateContainer !== null) {
+  const initialState = JSON.parse(initialStateContainer.textContent);
+  store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          <Compose />
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/containers/intersection_observer_article_container.js b/app/javascript/mastodon/containers/intersection_observer_article_container.js
new file mode 100644
index 000000000..b6f162199
--- /dev/null
+++ b/app/javascript/mastodon/containers/intersection_observer_article_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import IntersectionObserverArticle from '../components/intersection_observer_article';
+import { setHeight } from '../actions/height_cache';
+
+const makeMapStateToProps = (state, props) => ({
+  cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onHeightChange (key, id, height) {
+    dispatch(setHeight(key, id, height));
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle);
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 8287375c4..db2a5f269 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -2,21 +2,13 @@ import React from 'react';
 import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
 import configureStore from '../store/configureStore';
-import {
-  updateTimeline,
-  deleteFromTimelines,
-  refreshHomeTimeline,
-  connectTimeline,
-  disconnectTimeline,
-} from '../actions/timelines';
 import { showOnboardingOnce } from '../actions/onboarding';
-import { updateNotifications, refreshNotifications } from '../actions/notifications';
 import BrowserRouter from 'react-router-dom/BrowserRouter';
 import Route from 'react-router-dom/Route';
 import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext';
 import UI from '../features/ui';
 import { hydrateStore } from '../actions/store';
-import createStream from '../stream';
+import { connectUserStream } from '../actions/streaming';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
 const { localeData, messages } = getLocale();
@@ -39,74 +31,28 @@ export default class Mastodon extends React.PureComponent {
   };
 
   componentDidMount() {
-    const { locale }  = this.props;
-    const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']);
-    const accessToken = store.getState().getIn(['meta', 'access_token']);
-
-    const setupPolling = () => {
-      this.polling = setInterval(() => {
-        store.dispatch(refreshHomeTimeline());
-        store.dispatch(refreshNotifications());
-      }, 20000);
-    };
-
-    const clearPolling = () => {
-      clearInterval(this.polling);
-      this.polling = undefined;
-    };
-
-    this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', {
-
-      connected () {
-        clearPolling();
-        store.dispatch(connectTimeline('home'));
-      },
-
-      disconnected () {
-        setupPolling();
-        store.dispatch(disconnectTimeline('home'));
-      },
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          store.dispatch(deleteFromTimelines(data.payload));
-          break;
-        case 'notification':
-          store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
-          break;
-        }
-      },
-
-      reconnected () {
-        clearPolling();
-        store.dispatch(connectTimeline('home'));
-        store.dispatch(refreshHomeTimeline());
-        store.dispatch(refreshNotifications());
-      },
-
-    });
+    this.disconnect = store.dispatch(connectUserStream());
 
     // Desktop notifications
+    // Ask after 1 minute
     if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      Notification.requestPermission();
+      window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
+    }
+
+    // Protocol handler
+    // Ask after 5 minutes
+    if (typeof navigator.registerProtocolHandler !== 'undefined') {
+      const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
+      window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
     }
 
     store.dispatch(showOnboardingOnce());
   }
 
   componentWillUnmount () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
-    }
-
-    if (typeof this.polling !== 'undefined') {
-      clearInterval(this.polling);
-      this.polling = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/containers/media_gallery_container.js b/app/javascript/mastodon/containers/media_gallery_container.js
new file mode 100644
index 000000000..812c3d4e5
--- /dev/null
+++ b/app/javascript/mastodon/containers/media_gallery_container.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import MediaGallery from '../components/media_gallery';
+import { fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class MediaGalleryContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    media: PropTypes.array.isRequired,
+  };
+
+  handleOpenMedia = () => {}
+
+  render () {
+    const { locale, media, ...props } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <MediaGallery
+          {...props}
+          media={fromJS(media)}
+          onOpenMedia={this.handleOpenMedia}
+        />
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 9b7f984e0..e8821223d 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -14,6 +14,8 @@ import {
   favourite,
   unreblog,
   unfavourite,
+  pin,
+  unpin,
 } from '../actions/interactions';
 import {
   blockAccount,
@@ -75,6 +77,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', { url: status.get('url') }));
+  },
+
   onDelete (status) {
     if (!this.deleteModal) {
       dispatch(deleteStatus(status.get('id')));
diff --git a/app/javascript/mastodon/containers/video_container.js b/app/javascript/mastodon/containers/video_container.js
new file mode 100644
index 000000000..2fd353096
--- /dev/null
+++ b/app/javascript/mastodon/containers/video_container.js
@@ -0,0 +1,26 @@
+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/emoji.js b/app/javascript/mastodon/emoji.js
index 5695c86dd..d75f6f598 100644
--- a/app/javascript/mastodon/emoji.js
+++ b/app/javascript/mastodon/emoji.js
@@ -3,34 +3,70 @@ import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
-const excluded = ['™', '©', '®'];
-
-function emojify(str) {
-  // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
-  // and replacing valid unicode strings
-  // that _aren't_ within tags with an <img> version.
-  // The goal is to be the same as an emojione.regUnicode replacement, but faster.
-  let i = -1;
-  let insideTag = false;
-  let match;
-  while (++i < str.length) {
-    const char = str.charAt(i);
-    if (insideTag && char === '>') {
-      insideTag = false;
-    } else if (char === '<') {
-      insideTag = true;
-    } else if (!insideTag && (match = trie.search(str.substring(i)))) {
-      const unicodeStr = match;
-      if (unicodeStr in unicodeMapping && excluded.indexOf(unicodeStr) === -1) {
-        const [filename, shortCode] = unicodeMapping[unicodeStr];
-        const alt      = unicodeStr;
-        const replacement =  `<img draggable="false" class="emojione" alt="${alt}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
-        str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
-        i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
-      }
+const assetHost = process.env.CDN_HOST || '';
+
+const emojify = (str, customEmojis = {}) => {
+  let rtn = '';
+  for (;;) {
+    let match, i = 0, tag;
+    while (i < str.length && (tag = '<&'.indexOf(str[i])) === -1 && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
+      i += str.codePointAt(i) < 65536 ? 1 : 2;
+    }
+    if (i === str.length)
+      break;
+    else if (tag >= 0) {
+      const tagend = str.indexOf('>;'[tag], i + 1) + 1;
+      if (!tagend)
+        break;
+      rtn += str.slice(0, tagend);
+      str = str.slice(tagend);
+    } else if (str[i] === ':') {
+      try {
+        // if replacing :shortname: succeed, exit this block with "continue"
+        const closeColon = str.indexOf(':', i + 1) + 1;
+        if (!closeColon) throw null; // no pair of ':'
+        const lt = str.indexOf('<', i + 1);
+        if (!(lt === -1 || lt >= closeColon)) throw null; // tag appeared before closing ':'
+        const shortname = str.slice(i, closeColon);
+        if (shortname in customEmojis) {
+          rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`;
+          str = str.slice(closeColon);
+          continue;
+        }
+      } catch (e) {}
+      // replacing :shortname: failed
+      rtn += str.slice(0, i + 1);
+      str = str.slice(i + 1);
+    } else {
+      const [filename, shortCode] = unicodeMapping[match];
+      rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="${assetHost}/emoji/${filename}.svg" />`;
+      str = str.slice(i + match.length);
     }
   }
-  return str;
-}
+  return rtn + str;
+};
 
 export default emojify;
+
+export const buildCustomEmojis = customEmojis => {
+  const emojis = [];
+
+  customEmojis.forEach(emoji => {
+    const shortcode = emoji.get('shortcode');
+    const url       = emoji.get('url');
+    const name      = shortcode.replace(':', '');
+
+    emojis.push({
+      id: name,
+      name,
+      short_names: [name],
+      text: '',
+      emoticons: [],
+      keywords: [name],
+      imageUrl: url,
+      custom: true,
+    });
+  });
+
+  return emojis;
+};
diff --git a/app/javascript/mastodon/emoji_map.json b/app/javascript/mastodon/emoji_map.json
new file mode 100644
index 000000000..13753ba84
--- /dev/null
+++ b/app/javascript/mastodon/emoji_map.json
@@ -0,0 +1 @@
+{"😀":"1f600","😁":"1f601","😂":"1f602","🤣":"1f923","😃":"1f603","😄":"1f604","😅":"1f605","😆":"1f606","😉":"1f609","😊":"1f60a","😋":"1f60b","😎":"1f60e","😍":"1f60d","😘":"1f618","😗":"1f617","😙":"1f619","😚":"1f61a","☺":"263a","🙂":"1f642","🤗":"1f917","🤩":"1f929","🤔":"1f914","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🙄":"1f644","😏":"1f60f","😣":"1f623","😥":"1f625","😮":"1f62e","🤐":"1f910","😯":"1f62f","😪":"1f62a","😫":"1f62b","😴":"1f634","😌":"1f60c","😛":"1f61b","😜":"1f61c","😝":"1f61d","🤤":"1f924","😒":"1f612","😓":"1f613","😔":"1f614","😕":"1f615","🙃":"1f643","🤑":"1f911","😲":"1f632","☹":"2639","🙁":"1f641","😖":"1f616","😞":"1f61e","😟":"1f61f","😤":"1f624","😢":"1f622","😭":"1f62d","😦":"1f626","😧":"1f627","😨":"1f628","😩":"1f629","🤯":"1f92f","😬":"1f62c","😰":"1f630","😱":"1f631","😳":"1f633","🤪":"1f92a","😵":"1f635","😡":"1f621","😠":"1f620","🤬":"1f92c","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","😇":"1f607","🤠":"1f920","🤡":"1f921","🤥":"1f925","🤫":"1f92b","🤭":"1f92d","🧐":"1f9d0","🤓":"1f913","😈":"1f608","👿":"1f47f","👹":"1f479","👺":"1f47a","💀":"1f480","☠":"2620","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","💩":"1f4a9","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👨":"1f468","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🧔":"1f9d4","👱":"1f471","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🙇":"1f647","🤦":"1f926","🤷":"1f937","💆":"1f486","💇":"1f487","🚶":"1f6b6","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","🕴":"1f574","🗣":"1f5e3","👤":"1f464","👥":"1f465","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🏎":"1f3ce","🏍":"1f3cd","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","👫":"1f46b","👬":"1f46c","👭":"1f46d","💏":"1f48f","💑":"1f491","👪":"1f46a","🤳":"1f933","💪":"1f4aa","👈":"1f448","👉":"1f449","☝":"261d","👆":"1f446","🖕":"1f595","👇":"1f447","✌":"270c","🤞":"1f91e","🖖":"1f596","🤘":"1f918","🤙":"1f919","🖐":"1f590","✋":"270b","👌":"1f44c","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","🤚":"1f91a","👋":"1f44b","🤟":"1f91f","✍":"270d","👏":"1f44f","👐":"1f450","🙌":"1f64c","🤲":"1f932","🙏":"1f64f","🤝":"1f91d","💅":"1f485","👂":"1f442","👃":"1f443","👣":"1f463","👀":"1f440","👁":"1f441","🧠":"1f9e0","👅":"1f445","👄":"1f444","💋":"1f48b","💘":"1f498","❤":"2764","💓":"1f493","💔":"1f494","💕":"1f495","💖":"1f496","💗":"1f497","💙":"1f499","💚":"1f49a","💛":"1f49b","🧡":"1f9e1","💜":"1f49c","🖤":"1f5a4","💝":"1f49d","💞":"1f49e","💟":"1f49f","❣":"2763","💌":"1f48c","💤":"1f4a4","💢":"1f4a2","💣":"1f4a3","💥":"1f4a5","💦":"1f4a6","💨":"1f4a8","💫":"1f4ab","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","🕳":"1f573","👓":"1f453","🕶":"1f576","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","👠":"1f460","👡":"1f461","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🐶":"1f436","🐕":"1f415","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦒":"1f992","🐘":"1f418","🦏":"1f98f","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦉":"1f989","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🦀":"1f980","🦐":"1f990","🦑":"1f991","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🐞":"1f41e","🦗":"1f997","🕷":"1f577","🕸":"1f578","🦂":"1f982","💐":"1f490","🌸":"1f338","💮":"1f4ae","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🥝":"1f95d","🍅":"1f345","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🥒":"1f952","🥦":"1f966","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥞":"1f95e","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🥤":"1f964","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🏘":"1f3d8","🏙":"1f3d9","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🌌":"1f30c","🎠":"1f3a0","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🎰":"1f3b0","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🚲":"1f6b2","🛴":"1f6f4","🛵":"1f6f5","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🚧":"1f6a7","🛑":"1f6d1","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","⭐":"2b50","🌟":"1f31f","🌠":"1f320","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🎱":"1f3b1","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","🎯":"1f3af","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎮":"1f3ae","🕹":"1f579","🎲":"1f3b2","♠":"2660","♥":"2665","♦":"2666","♣":"2663","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🥁":"1f941","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","💹":"1f4b9","💱":"1f4b1","💲":"1f4b2","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚗":"2697","⚖":"2696","🔗":"1f517","⛓":"26d3","💉":"1f489","💊":"1f48a","🚬":"1f6ac","⚰":"26b0","⚱":"26b1","🗿":"1f5ff","🛢":"1f6e2","🔮":"1f52e","🛒":"1f6d2","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","✖":"2716","❌":"274c","❎":"274e","➕":"2795","➖":"2796","➗":"2797","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","©":"a9","®":"ae","™":"2122","🔟":"1f51f","💯":"1f4af","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","▪":"25aa","▫":"25ab","◻":"25fb","◼":"25fc","◽":"25fd","◾":"25fe","⬛":"2b1b","⬜":"2b1c","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔲":"1f532","🔳":"1f533","⚪":"26aa","⚫":"26ab","🔴":"1f534","🔵":"1f535","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🗣️":"1f5e3","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🏎️":"1f3ce","🏍️":"1f3cd","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","❤️":"2764","❣️":"2763","🗨️":"1f5e8","🗯️":"1f5ef","🕳️":"1f573","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏙️":"1f3d9","🏚️":"1f3da","⛩️":"26e9","♨️":"2668","🖼️":"1f5bc","🛣️":"1f6e3","🛤️":"1f6e4","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","🛏️":"1f6cf","🛋️":"1f6cb","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚗️":"2697","⚖️":"2696","⛓️":"26d3","⚰️":"26b0","⚱️":"26b1","🛢️":"1f6e2","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","✖️":"2716","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","‼️":"203c","⁉️":"2049","〰️":"3030","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","▪️":"25aa","▫️":"25ab","◻️":"25fb","◼️":"25fc","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","👨‍⚕":"1f468-200d-2695-fe0f","👩‍⚕":"1f469-200d-2695-fe0f","👨‍🎓":"1f468-200d-1f393","👩‍🎓":"1f469-200d-1f393","👨‍🏫":"1f468-200d-1f3eb","👩‍🏫":"1f469-200d-1f3eb","👨‍⚖":"1f468-200d-2696-fe0f","👩‍⚖":"1f469-200d-2696-fe0f","👨‍🌾":"1f468-200d-1f33e","👩‍🌾":"1f469-200d-1f33e","👨‍🍳":"1f468-200d-1f373","👩‍🍳":"1f469-200d-1f373","👨‍🔧":"1f468-200d-1f527","👩‍🔧":"1f469-200d-1f527","👨‍🏭":"1f468-200d-1f3ed","👩‍🏭":"1f469-200d-1f3ed","👨‍💼":"1f468-200d-1f4bc","👩‍💼":"1f469-200d-1f4bc","👨‍🔬":"1f468-200d-1f52c","👩‍🔬":"1f469-200d-1f52c","👨‍💻":"1f468-200d-1f4bb","👩‍💻":"1f469-200d-1f4bb","👨‍🎤":"1f468-200d-1f3a4","👩‍🎤":"1f469-200d-1f3a4","👨‍🎨":"1f468-200d-1f3a8","👩‍🎨":"1f469-200d-1f3a8","👨‍✈":"1f468-200d-2708-fe0f","👩‍✈":"1f469-200d-2708-fe0f","👨‍🚀":"1f468-200d-1f680","👩‍🚀":"1f469-200d-1f680","👨‍🚒":"1f468-200d-1f692","👩‍🚒":"1f469-200d-1f692","👮‍♂":"1f46e-200d-2642-fe0f","👮‍♀":"1f46e-200d-2640-fe0f","🕵‍♂":"1f575-fe0f-200d-2642-fe0f","🕵‍♀":"1f575-fe0f-200d-2640-fe0f","💂‍♂":"1f482-200d-2642-fe0f","💂‍♀":"1f482-200d-2640-fe0f","👷‍♂":"1f477-200d-2642-fe0f","👷‍♀":"1f477-200d-2640-fe0f","👳‍♂":"1f473-200d-2642-fe0f","👳‍♀":"1f473-200d-2640-fe0f","👱‍♂":"1f471-200d-2642-fe0f","👱‍♀":"1f471-200d-2640-fe0f","🧙‍♀":"1f9d9-200d-2640-fe0f","🧙‍♂":"1f9d9-200d-2642-fe0f","🧚‍♀":"1f9da-200d-2640-fe0f","🧚‍♂":"1f9da-200d-2642-fe0f","🧛‍♀":"1f9db-200d-2640-fe0f","🧛‍♂":"1f9db-200d-2642-fe0f","🧜‍♀":"1f9dc-200d-2640-fe0f","🧜‍♂":"1f9dc-200d-2642-fe0f","🧝‍♀":"1f9dd-200d-2640-fe0f","🧝‍♂":"1f9dd-200d-2642-fe0f","🧞‍♀":"1f9de-200d-2640-fe0f","🧞‍♂":"1f9de-200d-2642-fe0f","🧟‍♀":"1f9df-200d-2640-fe0f","🧟‍♂":"1f9df-200d-2642-fe0f","🙍‍♂":"1f64d-200d-2642-fe0f","🙍‍♀":"1f64d-200d-2640-fe0f","🙎‍♂":"1f64e-200d-2642-fe0f","🙎‍♀":"1f64e-200d-2640-fe0f","🙅‍♂":"1f645-200d-2642-fe0f","🙅‍♀":"1f645-200d-2640-fe0f","🙆‍♂":"1f646-200d-2642-fe0f","🙆‍♀":"1f646-200d-2640-fe0f","💁‍♂":"1f481-200d-2642-fe0f","💁‍♀":"1f481-200d-2640-fe0f","🙋‍♂":"1f64b-200d-2642-fe0f","🙋‍♀":"1f64b-200d-2640-fe0f","🙇‍♂":"1f647-200d-2642-fe0f","🙇‍♀":"1f647-200d-2640-fe0f","🤦‍♂":"1f926-200d-2642-fe0f","🤦‍♀":"1f926-200d-2640-fe0f","🤷‍♂":"1f937-200d-2642-fe0f","🤷‍♀":"1f937-200d-2640-fe0f","💆‍♂":"1f486-200d-2642-fe0f","💆‍♀":"1f486-200d-2640-fe0f","💇‍♂":"1f487-200d-2642-fe0f","💇‍♀":"1f487-200d-2640-fe0f","🚶‍♂":"1f6b6-200d-2642-fe0f","🚶‍♀":"1f6b6-200d-2640-fe0f","🏃‍♂":"1f3c3-200d-2642-fe0f","🏃‍♀":"1f3c3-200d-2640-fe0f","👯‍♂":"1f46f-200d-2642-fe0f","👯‍♀":"1f46f-200d-2640-fe0f","🧖‍♀":"1f9d6-200d-2640-fe0f","🧖‍♂":"1f9d6-200d-2642-fe0f","🧗‍♀":"1f9d7-200d-2640-fe0f","🧗‍♂":"1f9d7-200d-2642-fe0f","🧘‍♀":"1f9d8-200d-2640-fe0f","🧘‍♂":"1f9d8-200d-2642-fe0f","🏌‍♂":"1f3cc-fe0f-200d-2642-fe0f","🏌‍♀":"1f3cc-fe0f-200d-2640-fe0f","🏄‍♂":"1f3c4-200d-2642-fe0f","🏄‍♀":"1f3c4-200d-2640-fe0f","🚣‍♂":"1f6a3-200d-2642-fe0f","🚣‍♀":"1f6a3-200d-2640-fe0f","🏊‍♂":"1f3ca-200d-2642-fe0f","🏊‍♀":"1f3ca-200d-2640-fe0f","⛹‍♂":"26f9-fe0f-200d-2642-fe0f","⛹‍♀":"26f9-fe0f-200d-2640-fe0f","🏋‍♂":"1f3cb-fe0f-200d-2642-fe0f","🏋‍♀":"1f3cb-fe0f-200d-2640-fe0f","🚴‍♂":"1f6b4-200d-2642-fe0f","🚴‍♀":"1f6b4-200d-2640-fe0f","🚵‍♂":"1f6b5-200d-2642-fe0f","🚵‍♀":"1f6b5-200d-2640-fe0f","🤸‍♂":"1f938-200d-2642-fe0f","🤸‍♀":"1f938-200d-2640-fe0f","🤼‍♂":"1f93c-200d-2642-fe0f","🤼‍♀":"1f93c-200d-2640-fe0f","🤽‍♂":"1f93d-200d-2642-fe0f","🤽‍♀":"1f93d-200d-2640-fe0f","🤾‍♂":"1f93e-200d-2642-fe0f","🤾‍♀":"1f93e-200d-2640-fe0f","🤹‍♂":"1f939-200d-2642-fe0f","🤹‍♀":"1f939-200d-2640-fe0f","👨‍👦":"1f468-200d-1f466","👨‍👧":"1f468-200d-1f467","👩‍👦":"1f469-200d-1f466","👩‍👧":"1f469-200d-1f467","👁‍🗨":"1f441-200d-1f5e8","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳‍🌈":"1f3f3-fe0f-200d-1f308","👨‍⚕️":"1f468-200d-2695-fe0f","👨🏻‍⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼‍⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽‍⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾‍⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿‍⚕":"1f468-1f3ff-200d-2695-fe0f","👩‍⚕️":"1f469-200d-2695-fe0f","👩🏻‍⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼‍⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽‍⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾‍⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿‍⚕":"1f469-1f3ff-200d-2695-fe0f","👨🏻‍🎓":"1f468-1f3fb-200d-1f393","👨🏼‍🎓":"1f468-1f3fc-200d-1f393","👨🏽‍🎓":"1f468-1f3fd-200d-1f393","👨🏾‍🎓":"1f468-1f3fe-200d-1f393","👨🏿‍🎓":"1f468-1f3ff-200d-1f393","👩🏻‍🎓":"1f469-1f3fb-200d-1f393","👩🏼‍🎓":"1f469-1f3fc-200d-1f393","👩🏽‍🎓":"1f469-1f3fd-200d-1f393","👩🏾‍🎓":"1f469-1f3fe-200d-1f393","👩🏿‍🎓":"1f469-1f3ff-200d-1f393","👨🏻‍🏫":"1f468-1f3fb-200d-1f3eb","👨🏼‍🏫":"1f468-1f3fc-200d-1f3eb","👨🏽‍🏫":"1f468-1f3fd-200d-1f3eb","👨🏾‍🏫":"1f468-1f3fe-200d-1f3eb","👨🏿‍🏫":"1f468-1f3ff-200d-1f3eb","👩🏻‍🏫":"1f469-1f3fb-200d-1f3eb","👩🏼‍🏫":"1f469-1f3fc-200d-1f3eb","👩🏽‍🏫":"1f469-1f3fd-200d-1f3eb","👩🏾‍🏫":"1f469-1f3fe-200d-1f3eb","👩🏿‍🏫":"1f469-1f3ff-200d-1f3eb","👨‍⚖️":"1f468-200d-2696-fe0f","👨🏻‍⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼‍⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽‍⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾‍⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿‍⚖":"1f468-1f3ff-200d-2696-fe0f","👩‍⚖️":"1f469-200d-2696-fe0f","👩🏻‍⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼‍⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽‍⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾‍⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿‍⚖":"1f469-1f3ff-200d-2696-fe0f","👨🏻‍🌾":"1f468-1f3fb-200d-1f33e","👨🏼‍🌾":"1f468-1f3fc-200d-1f33e","👨🏽‍🌾":"1f468-1f3fd-200d-1f33e","👨🏾‍🌾":"1f468-1f3fe-200d-1f33e","👨🏿‍🌾":"1f468-1f3ff-200d-1f33e","👩🏻‍🌾":"1f469-1f3fb-200d-1f33e","👩🏼‍🌾":"1f469-1f3fc-200d-1f33e","👩🏽‍🌾":"1f469-1f3fd-200d-1f33e","👩🏾‍🌾":"1f469-1f3fe-200d-1f33e","👩🏿‍🌾":"1f469-1f3ff-200d-1f33e","👨🏻‍🍳":"1f468-1f3fb-200d-1f373","👨🏼‍🍳":"1f468-1f3fc-200d-1f373","👨🏽‍🍳":"1f468-1f3fd-200d-1f373","👨🏾‍🍳":"1f468-1f3fe-200d-1f373","👨🏿‍🍳":"1f468-1f3ff-200d-1f373","👩🏻‍🍳":"1f469-1f3fb-200d-1f373","👩🏼‍🍳":"1f469-1f3fc-200d-1f373","👩🏽‍🍳":"1f469-1f3fd-200d-1f373","👩🏾‍🍳":"1f469-1f3fe-200d-1f373","👩🏿‍🍳":"1f469-1f3ff-200d-1f373","👨🏻‍🔧":"1f468-1f3fb-200d-1f527","👨🏼‍🔧":"1f468-1f3fc-200d-1f527","👨🏽‍🔧":"1f468-1f3fd-200d-1f527","👨🏾‍🔧":"1f468-1f3fe-200d-1f527","👨🏿‍🔧":"1f468-1f3ff-200d-1f527","👩🏻‍🔧":"1f469-1f3fb-200d-1f527","👩🏼‍🔧":"1f469-1f3fc-200d-1f527","👩🏽‍🔧":"1f469-1f3fd-200d-1f527","👩🏾‍🔧":"1f469-1f3fe-200d-1f527","👩🏿‍🔧":"1f469-1f3ff-200d-1f527","👨🏻‍🏭":"1f468-1f3fb-200d-1f3ed","👨🏼‍🏭":"1f468-1f3fc-200d-1f3ed","👨🏽‍🏭":"1f468-1f3fd-200d-1f3ed","👨🏾‍🏭":"1f468-1f3fe-200d-1f3ed","👨🏿‍🏭":"1f468-1f3ff-200d-1f3ed","👩🏻‍🏭":"1f469-1f3fb-200d-1f3ed","👩🏼‍🏭":"1f469-1f3fc-200d-1f3ed","👩🏽‍🏭":"1f469-1f3fd-200d-1f3ed","👩🏾‍🏭":"1f469-1f3fe-200d-1f3ed","👩🏿‍🏭":"1f469-1f3ff-200d-1f3ed","👨🏻‍💼":"1f468-1f3fb-200d-1f4bc","👨🏼‍💼":"1f468-1f3fc-200d-1f4bc","👨🏽‍💼":"1f468-1f3fd-200d-1f4bc","👨🏾‍💼":"1f468-1f3fe-200d-1f4bc","👨🏿‍💼":"1f468-1f3ff-200d-1f4bc","👩🏻‍💼":"1f469-1f3fb-200d-1f4bc","👩🏼‍💼":"1f469-1f3fc-200d-1f4bc","👩🏽‍💼":"1f469-1f3fd-200d-1f4bc","👩🏾‍💼":"1f469-1f3fe-200d-1f4bc","👩🏿‍💼":"1f469-1f3ff-200d-1f4bc","👨🏻‍🔬":"1f468-1f3fb-200d-1f52c","👨🏼‍🔬":"1f468-1f3fc-200d-1f52c","👨🏽‍🔬":"1f468-1f3fd-200d-1f52c","👨🏾‍🔬":"1f468-1f3fe-200d-1f52c","👨🏿‍🔬":"1f468-1f3ff-200d-1f52c","👩🏻‍🔬":"1f469-1f3fb-200d-1f52c","👩🏼‍🔬":"1f469-1f3fc-200d-1f52c","👩🏽‍🔬":"1f469-1f3fd-200d-1f52c","👩🏾‍🔬":"1f469-1f3fe-200d-1f52c","👩🏿‍🔬":"1f469-1f3ff-200d-1f52c","👨🏻‍💻":"1f468-1f3fb-200d-1f4bb","👨🏼‍💻":"1f468-1f3fc-200d-1f4bb","👨🏽‍💻":"1f468-1f3fd-200d-1f4bb","👨🏾‍💻":"1f468-1f3fe-200d-1f4bb","👨🏿‍💻":"1f468-1f3ff-200d-1f4bb","👩🏻‍💻":"1f469-1f3fb-200d-1f4bb","👩🏼‍💻":"1f469-1f3fc-200d-1f4bb","👩🏽‍💻":"1f469-1f3fd-200d-1f4bb","👩🏾‍💻":"1f469-1f3fe-200d-1f4bb","👩🏿‍💻":"1f469-1f3ff-200d-1f4bb","👨🏻‍🎤":"1f468-1f3fb-200d-1f3a4","👨🏼‍🎤":"1f468-1f3fc-200d-1f3a4","👨🏽‍🎤":"1f468-1f3fd-200d-1f3a4","👨🏾‍🎤":"1f468-1f3fe-200d-1f3a4","👨🏿‍🎤":"1f468-1f3ff-200d-1f3a4","👩🏻‍🎤":"1f469-1f3fb-200d-1f3a4","👩🏼‍🎤":"1f469-1f3fc-200d-1f3a4","👩🏽‍🎤":"1f469-1f3fd-200d-1f3a4","👩🏾‍🎤":"1f469-1f3fe-200d-1f3a4","👩🏿‍🎤":"1f469-1f3ff-200d-1f3a4","👨🏻‍🎨":"1f468-1f3fb-200d-1f3a8","👨🏼‍🎨":"1f468-1f3fc-200d-1f3a8","👨🏽‍🎨":"1f468-1f3fd-200d-1f3a8","👨🏾‍🎨":"1f468-1f3fe-200d-1f3a8","👨🏿‍🎨":"1f468-1f3ff-200d-1f3a8","👩🏻‍🎨":"1f469-1f3fb-200d-1f3a8","👩🏼‍🎨":"1f469-1f3fc-200d-1f3a8","👩🏽‍🎨":"1f469-1f3fd-200d-1f3a8","👩🏾‍🎨":"1f469-1f3fe-200d-1f3a8","👩🏿‍🎨":"1f469-1f3ff-200d-1f3a8","👨‍✈️":"1f468-200d-2708-fe0f","👨🏻‍✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼‍✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽‍✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾‍✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿‍✈":"1f468-1f3ff-200d-2708-fe0f","👩‍✈️":"1f469-200d-2708-fe0f","👩🏻‍✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼‍✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽‍✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾‍✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿‍✈":"1f469-1f3ff-200d-2708-fe0f","👨🏻‍🚀":"1f468-1f3fb-200d-1f680","👨🏼‍🚀":"1f468-1f3fc-200d-1f680","👨🏽‍🚀":"1f468-1f3fd-200d-1f680","👨🏾‍🚀":"1f468-1f3fe-200d-1f680","👨🏿‍🚀":"1f468-1f3ff-200d-1f680","👩🏻‍🚀":"1f469-1f3fb-200d-1f680","👩🏼‍🚀":"1f469-1f3fc-200d-1f680","👩🏽‍🚀":"1f469-1f3fd-200d-1f680","👩🏾‍🚀":"1f469-1f3fe-200d-1f680","👩🏿‍🚀":"1f469-1f3ff-200d-1f680","👨🏻‍🚒":"1f468-1f3fb-200d-1f692","👨🏼‍🚒":"1f468-1f3fc-200d-1f692","👨🏽‍🚒":"1f468-1f3fd-200d-1f692","👨🏾‍🚒":"1f468-1f3fe-200d-1f692","👨🏿‍🚒":"1f468-1f3ff-200d-1f692","👩🏻‍🚒":"1f469-1f3fb-200d-1f692","👩🏼‍🚒":"1f469-1f3fc-200d-1f692","👩🏽‍🚒":"1f469-1f3fd-200d-1f692","👩🏾‍🚒":"1f469-1f3fe-200d-1f692","👩🏿‍🚒":"1f469-1f3ff-200d-1f692","👮‍♂️":"1f46e-200d-2642-fe0f","👮🏻‍♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼‍♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽‍♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾‍♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿‍♂":"1f46e-1f3ff-200d-2642-fe0f","👮‍♀️":"1f46e-200d-2640-fe0f","👮🏻‍♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼‍♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽‍♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾‍♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿‍♀":"1f46e-1f3ff-200d-2640-fe0f","🕵‍♂️":"1f575-fe0f-200d-2642-fe0f","🕵️‍♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻‍♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼‍♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽‍♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾‍♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿‍♂":"1f575-1f3ff-200d-2642-fe0f","🕵‍♀️":"1f575-fe0f-200d-2640-fe0f","🕵️‍♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻‍♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼‍♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽‍♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾‍♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿‍♀":"1f575-1f3ff-200d-2640-fe0f","💂‍♂️":"1f482-200d-2642-fe0f","💂🏻‍♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼‍♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽‍♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾‍♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿‍♂":"1f482-1f3ff-200d-2642-fe0f","💂‍♀️":"1f482-200d-2640-fe0f","💂🏻‍♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼‍♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽‍♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾‍♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿‍♀":"1f482-1f3ff-200d-2640-fe0f","👷‍♂️":"1f477-200d-2642-fe0f","👷🏻‍♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼‍♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽‍♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾‍♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿‍♂":"1f477-1f3ff-200d-2642-fe0f","👷‍♀️":"1f477-200d-2640-fe0f","👷🏻‍♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼‍♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽‍♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾‍♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿‍♀":"1f477-1f3ff-200d-2640-fe0f","👳‍♂️":"1f473-200d-2642-fe0f","👳🏻‍♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼‍♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽‍♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾‍♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿‍♂":"1f473-1f3ff-200d-2642-fe0f","👳‍♀️":"1f473-200d-2640-fe0f","👳🏻‍♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼‍♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽‍♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾‍♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿‍♀":"1f473-1f3ff-200d-2640-fe0f","👱‍♂️":"1f471-200d-2642-fe0f","👱🏻‍♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼‍♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽‍♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾‍♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿‍♂":"1f471-1f3ff-200d-2642-fe0f","👱‍♀️":"1f471-200d-2640-fe0f","👱🏻‍♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼‍♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽‍♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾‍♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿‍♀":"1f471-1f3ff-200d-2640-fe0f","🧙‍♀️":"1f9d9-200d-2640-fe0f","🧙🏻‍♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼‍♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽‍♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾‍♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿‍♀":"1f9d9-1f3ff-200d-2640-fe0f","🧙‍♂️":"1f9d9-200d-2642-fe0f","🧙🏻‍♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼‍♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽‍♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾‍♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿‍♂":"1f9d9-1f3ff-200d-2642-fe0f","🧚‍♀️":"1f9da-200d-2640-fe0f","🧚🏻‍♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼‍♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽‍♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾‍♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿‍♀":"1f9da-1f3ff-200d-2640-fe0f","🧚‍♂️":"1f9da-200d-2642-fe0f","🧚🏻‍♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼‍♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽‍♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾‍♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿‍♂":"1f9da-1f3ff-200d-2642-fe0f","🧛‍♀️":"1f9db-200d-2640-fe0f","🧛🏻‍♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼‍♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽‍♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾‍♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿‍♀":"1f9db-1f3ff-200d-2640-fe0f","🧛‍♂️":"1f9db-200d-2642-fe0f","🧛🏻‍♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼‍♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽‍♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾‍♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿‍♂":"1f9db-1f3ff-200d-2642-fe0f","🧜‍♀️":"1f9dc-200d-2640-fe0f","🧜🏻‍♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼‍♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽‍♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾‍♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿‍♀":"1f9dc-1f3ff-200d-2640-fe0f","🧜‍♂️":"1f9dc-200d-2642-fe0f","🧜🏻‍♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼‍♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽‍♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾‍♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿‍♂":"1f9dc-1f3ff-200d-2642-fe0f","🧝‍♀️":"1f9dd-200d-2640-fe0f","🧝🏻‍♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼‍♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽‍♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾‍♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿‍♀":"1f9dd-1f3ff-200d-2640-fe0f","🧝‍♂️":"1f9dd-200d-2642-fe0f","🧝🏻‍♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼‍♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽‍♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾‍♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿‍♂":"1f9dd-1f3ff-200d-2642-fe0f","🧞‍♀️":"1f9de-200d-2640-fe0f","🧞‍♂️":"1f9de-200d-2642-fe0f","🧟‍♀️":"1f9df-200d-2640-fe0f","🧟‍♂️":"1f9df-200d-2642-fe0f","🙍‍♂️":"1f64d-200d-2642-fe0f","🙍🏻‍♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼‍♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽‍♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾‍♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿‍♂":"1f64d-1f3ff-200d-2642-fe0f","🙍‍♀️":"1f64d-200d-2640-fe0f","🙍🏻‍♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼‍♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽‍♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾‍♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿‍♀":"1f64d-1f3ff-200d-2640-fe0f","🙎‍♂️":"1f64e-200d-2642-fe0f","🙎🏻‍♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼‍♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽‍♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾‍♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿‍♂":"1f64e-1f3ff-200d-2642-fe0f","🙎‍♀️":"1f64e-200d-2640-fe0f","🙎🏻‍♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼‍♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽‍♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾‍♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿‍♀":"1f64e-1f3ff-200d-2640-fe0f","🙅‍♂️":"1f645-200d-2642-fe0f","🙅🏻‍♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼‍♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽‍♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾‍♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿‍♂":"1f645-1f3ff-200d-2642-fe0f","🙅‍♀️":"1f645-200d-2640-fe0f","🙅🏻‍♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼‍♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽‍♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾‍♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿‍♀":"1f645-1f3ff-200d-2640-fe0f","🙆‍♂️":"1f646-200d-2642-fe0f","🙆🏻‍♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼‍♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽‍♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾‍♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿‍♂":"1f646-1f3ff-200d-2642-fe0f","🙆‍♀️":"1f646-200d-2640-fe0f","🙆🏻‍♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼‍♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽‍♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾‍♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿‍♀":"1f646-1f3ff-200d-2640-fe0f","💁‍♂️":"1f481-200d-2642-fe0f","💁🏻‍♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼‍♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽‍♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾‍♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿‍♂":"1f481-1f3ff-200d-2642-fe0f","💁‍♀️":"1f481-200d-2640-fe0f","💁🏻‍♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼‍♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽‍♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾‍♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿‍♀":"1f481-1f3ff-200d-2640-fe0f","🙋‍♂️":"1f64b-200d-2642-fe0f","🙋🏻‍♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼‍♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽‍♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾‍♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿‍♂":"1f64b-1f3ff-200d-2642-fe0f","🙋‍♀️":"1f64b-200d-2640-fe0f","🙋🏻‍♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼‍♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽‍♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾‍♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿‍♀":"1f64b-1f3ff-200d-2640-fe0f","🙇‍♂️":"1f647-200d-2642-fe0f","🙇🏻‍♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼‍♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽‍♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾‍♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿‍♂":"1f647-1f3ff-200d-2642-fe0f","🙇‍♀️":"1f647-200d-2640-fe0f","🙇🏻‍♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼‍♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽‍♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾‍♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿‍♀":"1f647-1f3ff-200d-2640-fe0f","🤦‍♂️":"1f926-200d-2642-fe0f","🤦🏻‍♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼‍♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽‍♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾‍♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿‍♂":"1f926-1f3ff-200d-2642-fe0f","🤦‍♀️":"1f926-200d-2640-fe0f","🤦🏻‍♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼‍♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽‍♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾‍♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿‍♀":"1f926-1f3ff-200d-2640-fe0f","🤷‍♂️":"1f937-200d-2642-fe0f","🤷🏻‍♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼‍♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽‍♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾‍♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿‍♂":"1f937-1f3ff-200d-2642-fe0f","🤷‍♀️":"1f937-200d-2640-fe0f","🤷🏻‍♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼‍♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽‍♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾‍♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿‍♀":"1f937-1f3ff-200d-2640-fe0f","💆‍♂️":"1f486-200d-2642-fe0f","💆🏻‍♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼‍♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽‍♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾‍♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿‍♂":"1f486-1f3ff-200d-2642-fe0f","💆‍♀️":"1f486-200d-2640-fe0f","💆🏻‍♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼‍♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽‍♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾‍♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿‍♀":"1f486-1f3ff-200d-2640-fe0f","💇‍♂️":"1f487-200d-2642-fe0f","💇🏻‍♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼‍♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽‍♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾‍♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿‍♂":"1f487-1f3ff-200d-2642-fe0f","💇‍♀️":"1f487-200d-2640-fe0f","💇🏻‍♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼‍♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽‍♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾‍♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿‍♀":"1f487-1f3ff-200d-2640-fe0f","🚶‍♂️":"1f6b6-200d-2642-fe0f","🚶🏻‍♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼‍♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽‍♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾‍♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿‍♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶‍♀️":"1f6b6-200d-2640-fe0f","🚶🏻‍♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼‍♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽‍♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾‍♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿‍♀":"1f6b6-1f3ff-200d-2640-fe0f","🏃‍♂️":"1f3c3-200d-2642-fe0f","🏃🏻‍♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼‍♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽‍♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾‍♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿‍♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃‍♀️":"1f3c3-200d-2640-fe0f","🏃🏻‍♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼‍♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽‍♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾‍♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿‍♀":"1f3c3-1f3ff-200d-2640-fe0f","👯‍♂️":"1f46f-200d-2642-fe0f","👯‍♀️":"1f46f-200d-2640-fe0f","🧖‍♀️":"1f9d6-200d-2640-fe0f","🧖🏻‍♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼‍♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽‍♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾‍♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿‍♀":"1f9d6-1f3ff-200d-2640-fe0f","🧖‍♂️":"1f9d6-200d-2642-fe0f","🧖🏻‍♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼‍♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽‍♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾‍♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿‍♂":"1f9d6-1f3ff-200d-2642-fe0f","🧗‍♀️":"1f9d7-200d-2640-fe0f","🧗🏻‍♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼‍♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽‍♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾‍♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿‍♀":"1f9d7-1f3ff-200d-2640-fe0f","🧗‍♂️":"1f9d7-200d-2642-fe0f","🧗🏻‍♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼‍♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽‍♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾‍♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿‍♂":"1f9d7-1f3ff-200d-2642-fe0f","🧘‍♀️":"1f9d8-200d-2640-fe0f","🧘🏻‍♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼‍♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽‍♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾‍♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿‍♀":"1f9d8-1f3ff-200d-2640-fe0f","🧘‍♂️":"1f9d8-200d-2642-fe0f","🧘🏻‍♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼‍♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽‍♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾‍♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿‍♂":"1f9d8-1f3ff-200d-2642-fe0f","🏌‍♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️‍♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻‍♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼‍♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽‍♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾‍♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿‍♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌‍♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️‍♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻‍♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼‍♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽‍♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾‍♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿‍♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄‍♂️":"1f3c4-200d-2642-fe0f","🏄🏻‍♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼‍♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽‍♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾‍♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿‍♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄‍♀️":"1f3c4-200d-2640-fe0f","🏄🏻‍♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼‍♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽‍♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾‍♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿‍♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣‍♂️":"1f6a3-200d-2642-fe0f","🚣🏻‍♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼‍♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽‍♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾‍♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿‍♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣‍♀️":"1f6a3-200d-2640-fe0f","🚣🏻‍♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼‍♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽‍♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾‍♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿‍♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊‍♂️":"1f3ca-200d-2642-fe0f","🏊🏻‍♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼‍♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽‍♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾‍♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿‍♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊‍♀️":"1f3ca-200d-2640-fe0f","🏊🏻‍♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼‍♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽‍♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾‍♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿‍♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹‍♂️":"26f9-fe0f-200d-2642-fe0f","⛹️‍♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻‍♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼‍♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽‍♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾‍♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿‍♂":"26f9-1f3ff-200d-2642-fe0f","⛹‍♀️":"26f9-fe0f-200d-2640-fe0f","⛹️‍♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻‍♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼‍♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽‍♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾‍♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿‍♀":"26f9-1f3ff-200d-2640-fe0f","🏋‍♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️‍♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻‍♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼‍♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽‍♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾‍♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿‍♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋‍♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️‍♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻‍♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼‍♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽‍♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾‍♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿‍♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴‍♂️":"1f6b4-200d-2642-fe0f","🚴🏻‍♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼‍♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽‍♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾‍♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿‍♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴‍♀️":"1f6b4-200d-2640-fe0f","🚴🏻‍♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼‍♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽‍♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾‍♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿‍♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵‍♂️":"1f6b5-200d-2642-fe0f","🚵🏻‍♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼‍♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽‍♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾‍♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿‍♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵‍♀️":"1f6b5-200d-2640-fe0f","🚵🏻‍♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼‍♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽‍♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾‍♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿‍♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸‍♂️":"1f938-200d-2642-fe0f","🤸🏻‍♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼‍♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽‍♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾‍♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿‍♂":"1f938-1f3ff-200d-2642-fe0f","🤸‍♀️":"1f938-200d-2640-fe0f","🤸🏻‍♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼‍♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽‍♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾‍♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿‍♀":"1f938-1f3ff-200d-2640-fe0f","🤼‍♂️":"1f93c-200d-2642-fe0f","🤼‍♀️":"1f93c-200d-2640-fe0f","🤽‍♂️":"1f93d-200d-2642-fe0f","🤽🏻‍♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼‍♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽‍♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾‍♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿‍♂":"1f93d-1f3ff-200d-2642-fe0f","🤽‍♀️":"1f93d-200d-2640-fe0f","🤽🏻‍♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼‍♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽‍♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾‍♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿‍♀":"1f93d-1f3ff-200d-2640-fe0f","🤾‍♂️":"1f93e-200d-2642-fe0f","🤾🏻‍♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼‍♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽‍♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾‍♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿‍♂":"1f93e-1f3ff-200d-2642-fe0f","🤾‍♀️":"1f93e-200d-2640-fe0f","🤾🏻‍♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼‍♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽‍♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾‍♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿‍♀":"1f93e-1f3ff-200d-2640-fe0f","🤹‍♂️":"1f939-200d-2642-fe0f","🤹🏻‍♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼‍♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽‍♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾‍♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿‍♂":"1f939-1f3ff-200d-2642-fe0f","🤹‍♀️":"1f939-200d-2640-fe0f","🤹🏻‍♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼‍♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽‍♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾‍♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿‍♀":"1f939-1f3ff-200d-2640-fe0f","👁‍🗨️":"1f441-200d-1f5e8","👁️‍🗨":"1f441-200d-1f5e8","🏳️‍🌈":"1f3f3-fe0f-200d-1f308","👨🏻‍⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼‍⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽‍⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾‍⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿‍⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻‍⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼‍⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽‍⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾‍⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿‍⚕️":"1f469-1f3ff-200d-2695-fe0f","👨🏻‍⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼‍⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽‍⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾‍⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿‍⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻‍⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼‍⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽‍⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾‍⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿‍⚖️":"1f469-1f3ff-200d-2696-fe0f","👨🏻‍✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼‍✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽‍✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾‍✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿‍✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻‍✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼‍✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽‍✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾‍✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿‍✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻‍♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼‍♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽‍♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾‍♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿‍♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻‍♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼‍♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽‍♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾‍♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿‍♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️‍♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻‍♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼‍♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽‍♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾‍♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿‍♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️‍♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻‍♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼‍♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽‍♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾‍♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿‍♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻‍♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼‍♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽‍♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾‍♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿‍♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻‍♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼‍♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽‍♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾‍♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿‍♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻‍♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼‍♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽‍♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾‍♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿‍♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻‍♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼‍♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽‍♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾‍♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿‍♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻‍♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼‍♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽‍♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾‍♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿‍♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻‍♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼‍♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽‍♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾‍♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿‍♀️":"1f473-1f3ff-200d-2640-fe0f","👱🏻‍♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼‍♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽‍♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾‍♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿‍♂️":"1f471-1f3ff-200d-2642-fe0f","👱🏻‍♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼‍♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽‍♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾‍♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿‍♀️":"1f471-1f3ff-200d-2640-fe0f","🧙🏻‍♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼‍♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽‍♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾‍♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿‍♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧙🏻‍♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼‍♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽‍♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾‍♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿‍♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧚🏻‍♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼‍♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽‍♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾‍♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿‍♀️":"1f9da-1f3ff-200d-2640-fe0f","🧚🏻‍♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼‍♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽‍♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾‍♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿‍♂️":"1f9da-1f3ff-200d-2642-fe0f","🧛🏻‍♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼‍♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽‍♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾‍♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿‍♀️":"1f9db-1f3ff-200d-2640-fe0f","🧛🏻‍♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼‍♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽‍♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾‍♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿‍♂️":"1f9db-1f3ff-200d-2642-fe0f","🧜🏻‍♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼‍♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽‍♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾‍♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿‍♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧜🏻‍♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼‍♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽‍♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾‍♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿‍♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧝🏻‍♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼‍♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽‍♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾‍♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿‍♀️":"1f9dd-1f3ff-200d-2640-fe0f","🧝🏻‍♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼‍♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽‍♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾‍♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿‍♂️":"1f9dd-1f3ff-200d-2642-fe0f","🙍🏻‍♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼‍♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽‍♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾‍♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿‍♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻‍♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼‍♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽‍♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾‍♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿‍♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻‍♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼‍♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽‍♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾‍♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿‍♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻‍♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼‍♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽‍♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾‍♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿‍♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻‍♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼‍♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽‍♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾‍♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿‍♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻‍♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼‍♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽‍♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾‍♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿‍♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻‍♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼‍♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽‍♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾‍♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿‍♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻‍♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼‍♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽‍♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾‍♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿‍♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻‍♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼‍♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽‍♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾‍♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿‍♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻‍♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼‍♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽‍♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾‍♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿‍♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻‍♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼‍♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽‍♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾‍♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿‍♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻‍♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼‍♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽‍♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾‍♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿‍♀️":"1f64b-1f3ff-200d-2640-fe0f","🙇🏻‍♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼‍♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽‍♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾‍♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿‍♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻‍♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼‍♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽‍♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾‍♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿‍♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻‍♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼‍♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽‍♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾‍♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿‍♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻‍♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼‍♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽‍♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾‍♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿‍♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻‍♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼‍♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽‍♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾‍♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿‍♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻‍♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼‍♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽‍♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾‍♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿‍♀️":"1f937-1f3ff-200d-2640-fe0f","💆🏻‍♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼‍♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽‍♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾‍♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿‍♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻‍♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼‍♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽‍♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾‍♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿‍♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻‍♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼‍♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽‍♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾‍♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿‍♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻‍♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼‍♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽‍♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾‍♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿‍♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻‍♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼‍♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽‍♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾‍♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿‍♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻‍♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼‍♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽‍♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾‍♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿‍♀️":"1f6b6-1f3ff-200d-2640-fe0f","🏃🏻‍♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼‍♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽‍♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾‍♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿‍♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻‍♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼‍♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽‍♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾‍♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿‍♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻‍♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼‍♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽‍♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾‍♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿‍♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧖🏻‍♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼‍♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽‍♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾‍♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿‍♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧗🏻‍♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼‍♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽‍♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾‍♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿‍♀️":"1f9d7-1f3ff-200d-2640-fe0f","🧗🏻‍♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼‍♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽‍♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾‍♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿‍♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧘🏻‍♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼‍♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽‍♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾‍♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿‍♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧘🏻‍♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼‍♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽‍♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾‍♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿‍♂️":"1f9d8-1f3ff-200d-2642-fe0f","🏌️‍♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻‍♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼‍♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽‍♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾‍♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿‍♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️‍♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻‍♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼‍♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽‍♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾‍♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿‍♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻‍♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼‍♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽‍♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾‍♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿‍♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻‍♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼‍♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽‍♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾‍♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿‍♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻‍♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼‍♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽‍♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾‍♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿‍♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻‍♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼‍♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽‍♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾‍♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿‍♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻‍♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼‍♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽‍♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾‍♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿‍♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻‍♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼‍♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽‍♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾‍♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿‍♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️‍♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻‍♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼‍♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽‍♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾‍♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿‍♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️‍♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻‍♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼‍♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽‍♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾‍♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿‍♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️‍♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻‍♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼‍♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽‍♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾‍♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿‍♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️‍♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻‍♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼‍♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽‍♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾‍♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿‍♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻‍♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼‍♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽‍♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾‍♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿‍♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻‍♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼‍♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽‍♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾‍♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿‍♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻‍♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼‍♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽‍♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾‍♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿‍♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻‍♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼‍♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽‍♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾‍♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿‍♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻‍♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼‍♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽‍♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾‍♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿‍♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻‍♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼‍♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽‍♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾‍♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿‍♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻‍♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼‍♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽‍♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾‍♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿‍♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻‍♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼‍♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽‍♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾‍♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿‍♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻‍♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼‍♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽‍♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾‍♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿‍♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻‍♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼‍♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽‍♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾‍♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿‍♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻‍♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼‍♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽‍♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾‍♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿‍♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻‍♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼‍♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽‍♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾‍♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿‍♀️":"1f939-1f3ff-200d-2640-fe0f","👩‍❤‍👨":"1f469-200d-2764-fe0f-200d-1f468","👨‍❤‍👨":"1f468-200d-2764-fe0f-200d-1f468","👩‍❤‍👩":"1f469-200d-2764-fe0f-200d-1f469","👨‍👩‍👦":"1f468-200d-1f469-200d-1f466","👨‍👩‍👧":"1f468-200d-1f469-200d-1f467","👨‍👨‍👦":"1f468-200d-1f468-200d-1f466","👨‍👨‍👧":"1f468-200d-1f468-200d-1f467","👩‍👩‍👦":"1f469-200d-1f469-200d-1f466","👩‍👩‍👧":"1f469-200d-1f469-200d-1f467","👨‍👦‍👦":"1f468-200d-1f466-200d-1f466","👨‍👧‍👦":"1f468-200d-1f467-200d-1f466","👨‍👧‍👧":"1f468-200d-1f467-200d-1f467","👩‍👦‍👦":"1f469-200d-1f466-200d-1f466","👩‍👧‍👦":"1f469-200d-1f467-200d-1f466","👩‍👧‍👧":"1f469-200d-1f467-200d-1f467","👁️‍🗨️":"1f441-200d-1f5e8","👩‍❤️‍👨":"1f469-200d-2764-fe0f-200d-1f468","👨‍❤️‍👨":"1f468-200d-2764-fe0f-200d-1f468","👩‍❤️‍👩":"1f469-200d-2764-fe0f-200d-1f469","👩‍❤‍💋‍👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨‍❤‍💋‍👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩‍❤‍💋‍👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","👨‍👩‍👧‍👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨‍👩‍👦‍👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨‍👩‍👧‍👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨‍👨‍👧‍👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨‍👨‍👦‍👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨‍👨‍👧‍👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩‍👩‍👧‍👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩‍👩‍👦‍👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩‍👩‍👧‍👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴󠁧󠁢󠁥󠁮󠁧󠁿":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴󠁧󠁢󠁳󠁣󠁴󠁿":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴󠁧󠁢󠁷󠁬󠁳󠁿":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩‍❤️‍💋‍👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨‍❤️‍💋‍👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩‍❤️‍💋‍👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469"}
\ No newline at end of file
diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js
index 985e9dbcb..2296497b0 100644
--- a/app/javascript/mastodon/emojione_light.js
+++ b/app/javascript/mastodon/emojione_light.js
@@ -1,11 +1,38 @@
 // @preval
-// Force tree shaking on emojione by exposing just a subset of its functionality
+// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
 
-const emojione = require('emojione');
+const emojis         = require('./emoji_map.json');
+const { emojiIndex } = require('emoji-mart');
+const excluded       = ['®', '©', '™'];
+const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿'];
+const shortcodeMap   = {};
 
-const mappedUnicode = emojione.mapUnicodeToShort();
+Object.keys(emojiIndex.emojis).forEach(key => {
+  shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
+});
 
-module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap)
-  .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
-  .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] }))
-  .reduce((x, y) => Object.assign(x, y), { });
+const stripModifiers = unicode => {
+  skins.forEach(tone => {
+    unicode = unicode.replace(tone, '');
+  });
+
+  return unicode;
+};
+
+Object.keys(emojis).forEach(key => {
+  if (excluded.includes(key)) {
+    delete emojis[key];
+    return;
+  }
+
+  const normalizedKey = stripModifiers(key);
+  let shortcode       = shortcodeMap[normalizedKey];
+
+  if (!shortcode) {
+    shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
+  }
+
+  emojis[key] = [emojis[key], shortcode];
+});
+
+module.exports.unicodeMapping = emojis;
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index c12c0889e..9e8fea69d 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -26,7 +26,7 @@ export default class ActionBar extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     onFollow: PropTypes.func,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 9d7bc82c0..5402d6753 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -4,8 +4,6 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
 import Motion from 'react-motion/lib/Motion';
@@ -16,7 +14,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
 });
 
 const makeMapStateToProps = () => {
@@ -82,7 +80,7 @@ export default class Header extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     onFollow: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     autoPlayGif: PropTypes.bool.isRequired,
@@ -95,15 +93,10 @@ export default class Header extends ImmutablePureComponent {
       return null;
     }
 
-    let displayName = account.get('display_name');
     let info        = '';
     let actionBtn   = '';
     let lockedIcon  = '';
 
-    if (displayName.length === 0) {
-      displayName = account.get('username');
-    }
-
     if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
       info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
     }
@@ -112,7 +105,7 @@ export default class Header extends ImmutablePureComponent {
       if (account.getIn(['relationship', 'requested'])) {
         actionBtn = (
           <div className='account--action-button'>
-            <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
+            <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />
           </div>
         );
       } else if (!account.getIn(['relationship', 'blocking'])) {
@@ -128,15 +121,15 @@ export default class Header extends ImmutablePureComponent {
       lockedIcon = <i className='fa fa-lock' />;
     }
 
-    const content         = { __html: emojify(account.get('note')) };
-    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const content         = { __html: account.get('note_emojified') };
+    const displayNameHtml = { __html: account.get('display_name_html') };
 
     return (
       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
         <div>
           <Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
 
-          <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
+          <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
           <div className='account__header__content' dangerouslySetInnerHTML={content} />
 
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index 0cfd98f23..2a88addc4 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -16,9 +16,9 @@ import { ScrollContainer } from 'react-router-scroll';
 import LoadMore from '../../components/load_more';
 
 const mapStateToProps = (state, props) => ({
-  medias: getAccountGallery(state, Number(props.params.accountId)),
-  isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'isLoading']),
-  hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'next']),
+  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']),
   autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
 });
 
@@ -35,20 +35,20 @@ export default class AccountGallery extends ImmutablePureComponent {
   };
 
   componentDidMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
     }
   }
 
   handleScrollToBottom = () => {
     if (this.props.hasMore) {
-      this.props.dispatch(expandAccountMediaTimeline(Number(this.props.params.accountId)));
+      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 09883d7d6..c3cd4e55d 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -10,7 +10,7 @@ export default class Header extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index baa81bbc2..9ad13a231 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -7,10 +7,10 @@ import {
   unfollowAccount,
   blockAccount,
   unblockAccount,
-  muteAccount,
   unmuteAccount,
 } from '../../../actions/accounts';
 import { mentionCompose } from '../../../actions/compose';
+import { initMuteModal } from '../../../actions/mutes';
 import { initReport } from '../../../actions/reports';
 import { openModal } from '../../../actions/modal';
 import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
@@ -19,7 +19,6 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 const messages = defineMessages({
   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
   blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
-  muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' },
   blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
 });
 
@@ -27,7 +26,7 @@ const makeMapStateToProps = () => {
   const getAccount = makeGetAccount();
 
   const mapStateToProps = (state, { accountId }) => ({
-    account: getAccount(state, Number(accountId)),
+    account: getAccount(state, accountId),
     me: state.getIn(['meta', 'me']),
     unfollowModal: state.getIn(['meta', 'unfollow_modal']),
   });
@@ -38,7 +37,7 @@ const makeMapStateToProps = () => {
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onFollow (account) {
-    if (account.getIn(['relationship', 'following'])) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
       if (this.unfollowModal) {
         dispatch(openModal('CONFIRM', {
           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
@@ -77,11 +76,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     if (account.getIn(['relationship', 'muting'])) {
       dispatch(unmuteAccount(account.get('id')));
     } else {
-      dispatch(openModal('CONFIRM', {
-        message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-        confirm: intl.formatMessage(messages.muteConfirm),
-        onConfirm: () => dispatch(muteAccount(account.get('id'))),
-      }));
+      dispatch(initMuteModal(account));
     }
   },
 
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index cbe66d635..e3b864aee 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -13,9 +13,9 @@ import { List as ImmutableList } from 'immutable';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], ImmutableList()),
-  isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'isLoading']),
-  hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'next']),
+  statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
+  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
   me: state.getIn(['meta', 'me']),
 });
 
@@ -28,24 +28,24 @@ export default class AccountTimeline extends ImmutablePureComponent {
     statusIds: ImmutablePropTypes.list,
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(refreshAccountTimeline(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(refreshAccountTimeline(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
     }
   }
 
   handleScrollToBottom = () => {
     if (!this.props.isLoading && this.props.hasMore) {
-      this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
+      this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index 27f977e85..62b1c8ee9 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header';
 import {
   refreshCommunityTimeline,
   expandCommunityTimeline,
-  updateTimeline,
-  deleteFromTimelines,
-  connectTimeline,
-  disconnectTimeline,
 } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
-import createStream from '../../stream';
+import { connectCommunityStream } from '../../actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'column.community', defaultMessage: 'Local timeline' },
@@ -23,8 +19,6 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
-  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
-  accessToken: state.getIn(['meta', 'access_token']),
 });
 
 @connect(mapStateToProps)
@@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent {
     dispatch: PropTypes.func.isRequired,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
-    streamingAPIBaseURL: PropTypes.string.isRequired,
-    accessToken: PropTypes.string.isRequired,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
   };
@@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent {
   }
 
   componentDidMount () {
-    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+    const { dispatch } = this.props;
 
     dispatch(refreshCommunityTimeline());
-
-    if (typeof this._subscription !== 'undefined') {
-      return;
-    }
-
-    this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
-
-      connected () {
-        dispatch(connectTimeline('community'));
-      },
-
-      reconnected () {
-        dispatch(connectTimeline('community'));
-      },
-
-      disconnected () {
-        dispatch(disconnectTimeline('community'));
-      },
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          dispatch(updateTimeline('community', JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          dispatch(deleteFromTimelines(data.payload));
-          break;
-        }
-      },
-
-    });
+    this.disconnect = dispatch(connectCommunityStream());
   }
 
   componentWillUnmount () {
-    if (typeof this._subscription !== 'undefined') {
-      this._subscription.close();
-      this._subscription = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
index ebfa3c247..e7de3716b 100644
--- a/app/javascript/mastodon/features/compose/components/autosuggest_account.js
+++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
@@ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
 
     return (
       <div className='autosuggest-account'>
-        <div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div>
+        <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
         <DisplayName account={account} />
       </div>
     );
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 0027783b4..b85105c53 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -13,7 +13,7 @@ import SpoilerButtonContainer from '../containers/spoiler_button_container';
 import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
 import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container';
 import SensitiveButtonContainer from '../containers/sensitive_button_container';
-import EmojiPickerDropdown from './emoji_picker_dropdown';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 import UploadFormContainer from '../containers/upload_form_container';
 import WarningContainer from '../containers/warning_container';
 import { isMobile } from '../../../is_mobile';
@@ -46,16 +46,19 @@ export default class ComposeForm extends ImmutablePureComponent {
     preselectDate: PropTypes.instanceOf(Date),
     is_submitting: PropTypes.bool,
     is_uploading: PropTypes.bool,
-    me: PropTypes.number,
+    me: PropTypes.string,
     onChange: PropTypes.func.isRequired,
     onSubmit: PropTypes.func.isRequired,
     onClearSuggestions: PropTypes.func.isRequired,
     onFetchSuggestions: PropTypes.func.isRequired,
+    onPrivacyChange: PropTypes.func.isRequired,
     onSuggestionSelected: PropTypes.func.isRequired,
     onChangeSpoilerText: PropTypes.func.isRequired,
     onPaste: PropTypes.func.isRequired,
     onPickEmoji: PropTypes.func.isRequired,
     showSearch: PropTypes.bool,
+    settings : ImmutablePropTypes.map.isRequired,
+    filesAttached : PropTypes.bool,
   };
 
   static defaultProps = {
@@ -72,6 +75,11 @@ export default class ComposeForm extends ImmutablePureComponent {
     }
   }
 
+  handleSubmit2 = () => {
+    this.props.onPrivacyChange(this.props.settings.get('side_arm'));
+    this.handleSubmit();
+  }
+
   handleSubmit = () => {
     if (this.props.text !== this.autosuggestTextarea.textarea.value) {
       // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
@@ -142,25 +150,65 @@ export default class ComposeForm extends ImmutablePureComponent {
 
   handleEmojiPick = (data) => {
     const position     = this.autosuggestTextarea.textarea.selectionStart;
-    const emojiChar    = data.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join('');
+    const emojiChar    = data.native;
     this._restoreCaret = position + emojiChar.length + 1;
     this.props.onPickEmoji(position, data);
   }
 
   render () {
-    const { intl, onPaste, showSearch } = this.props;
+    const { intl, onPaste, showSearch, filesAttached } = this.props;
     const disabled = this.props.is_submitting;
-    const maybeEye = this.props.advanced_options.get('do_not_federate') ? ' 👁️' : '';
+    const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
     const text     = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
 
+    const secondaryVisibility = this.props.settings.get('side_arm');
+    const isWideView = this.props.settings.get('stretch');
+    let showSideArm = secondaryVisibility !== 'none';
+
     let publishText = '';
+    let publishText2 = '';
+
+    const privacyIcons = {
+      none: '',
+      public: 'globe',
+      unlisted: 'unlock-alt',
+      private: 'lock',
+      direct: 'envelope',
+    };
 
-    if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
-      publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
+    if (showSideArm) {
+      publishText = (
+        <span>
+          {
+            <i
+              className={`fa fa-${privacyIcons[this.props.privacy]}`}
+              style={{
+                paddingRight: (filesAttached || !isWideView) ? '0' : '5px',
+              }}
+            />
+          }{
+            (filesAttached || !isWideView) ? '' :
+              intl.formatMessage(messages.publish)
+          }
+        </span>
+      );
+
+      publishText2 = (
+        <i
+          className={`fa fa-${privacyIcons[secondaryVisibility]}`}
+          aria-label={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`}
+        />
+      );
     } else {
-      publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+      if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
+        publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
+      } else {
+        publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+      }
     }
 
+    const submitDisabled = disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0);
+
     return (
       <div className='compose-form'>
         <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
@@ -210,7 +258,24 @@ export default class ComposeForm extends ImmutablePureComponent {
 
           <div className='compose-form__publish'>
             <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
-            <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div>
+            <div className='compose-form__publish-button-wrapper'>
+              {
+                showSideArm ?
+                  <Button
+                    className='compose-form__publish__side-arm'
+                    text={publishText2}
+                    onClick={this.handleSubmit2}
+                    disabled={submitDisabled}
+                  /> : ''
+              }
+              <Button
+                className='compose-form__publish__primary'
+                text={publishText}
+                onClick={this.handleSubmit}
+                disabled={submitDisabled}
+                block
+              />
+            </div>
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index 9d05b7a34..621cc21ce 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -1,12 +1,19 @@
 import React from 'react';
-import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl } from 'react-intl';
-import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import { Picker, Emoji } from 'emoji-mart';
+import { Overlay } from 'react-overlays';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import detectPassiveEvents from 'detect-passive-events';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
   emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
   people: { id: 'emoji_button.people', defaultMessage: 'People' },
   nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
   food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
@@ -17,48 +24,250 @@ const messages = defineMessages({
   flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 });
 
-const settings = {
-  imageType: 'png',
-  sprites: false,
-  imagePathPNG: '/emoji/',
-};
+const assetHost = process.env.CDN_HOST || '';
+const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
-let EmojiPicker; // load asynchronously
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = (e) => {
+    const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1;
+    this.props.onSelect(modifier);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  }
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  }
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    onClose: PropTypes.func.isRequired,
+    onPick: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    placement: PropTypes.string,
+    arrowOffsetLeft: PropTypes.string,
+    arrowOffsetTop: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+    placement: 'bottom',
+  };
+
+  state = {
+    modifierOpen: false,
+    modifier: 1,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  getI18n = () => {
+    const { intl } = this.props;
+
+    return {
+      search: intl.formatMessage(messages.emoji_search),
+      notfound: intl.formatMessage(messages.emoji_not_found),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  }
+
+  handleClick = emoji => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+
+    this.props.onClose();
+    this.props.onPick(emoji);
+  }
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  }
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  }
+
+  handleModifierChange = modifier => {
+    if (modifier !== this.state.modifier) {
+      this.setState({ modifier });
+    }
+  }
+
+  render () {
+    const { style, intl } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { modifierOpen, modifier } = this.state;
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <Picker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          skin={modifier}
+          backgroundImageFn={backgroundImageFn}
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={modifier}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
 
 @injectIntl
 export default class EmojiPickerDropdown extends React.PureComponent {
 
   static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
     intl: PropTypes.object.isRequired,
     onPickEmoji: PropTypes.func.isRequired,
   };
 
   state = {
     active: false,
-    loading: false,
   };
 
   setRef = (c) => {
     this.dropdown = c;
   }
 
-  handleChange = (data) => {
-    this.dropdown.hide();
-    this.props.onPickEmoji(data);
-  }
-
   onShowDropdown = () => {
     this.setState({ active: true });
-    if (!EmojiPicker) {
-      this.setState({ loading: true });
-      EmojiPickerAsync().then(TheEmojiPicker => {
-        EmojiPicker = TheEmojiPicker.default;
-        this.setState({ loading: false });
-      }).catch(() => {
-        // TODO: show the user an error?
-        this.setState({ loading: false });
-      });
-    }
   }
 
   onHideDropdown = () => {
@@ -66,7 +275,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
   }
 
   onToggle = (e) => {
-    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+    if (!e.key || e.key === 'Enter') {
       if (this.state.active) {
         this.onHideDropdown();
       } else {
@@ -75,70 +284,43 @@ export default class EmojiPickerDropdown extends React.PureComponent {
     }
   }
 
-  onEmojiPickerKeyDown = (e) => {
+  handleKeyDown = e => {
     if (e.key === 'Escape') {
       this.onHideDropdown();
     }
   }
 
-  render () {
-    const { intl } = this.props;
+  setTargetRef = c => {
+    this.target = c;
+  }
 
-    const categories = {
-      people: {
-        title: intl.formatMessage(messages.people),
-        emoji: 'smile',
-      },
-      nature: {
-        title: intl.formatMessage(messages.nature),
-        emoji: 'hamster',
-      },
-      food: {
-        title: intl.formatMessage(messages.food),
-        emoji: 'pizza',
-      },
-      activity: {
-        title: intl.formatMessage(messages.activity),
-        emoji: 'soccer',
-      },
-      travel: {
-        title: intl.formatMessage(messages.travel),
-        emoji: 'earth_americas',
-      },
-      objects: {
-        title: intl.formatMessage(messages.objects),
-        emoji: 'bulb',
-      },
-      symbols: {
-        title: intl.formatMessage(messages.symbols),
-        emoji: 'clock9',
-      },
-      flags: {
-        title: intl.formatMessage(messages.flags),
-        emoji: 'flag_gb',
-      },
-    };
+  findTarget = () => {
+    return this.target;
+  }
 
-    const { active, loading } = this.state;
+  render () {
+    const { intl, onPickEmoji } = this.props;
     const title = intl.formatMessage(messages.emoji);
+    const { active } = this.state;
 
     return (
-      <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
-        <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onKeyDown={this.onToggle} tabIndex={0} >
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
           <img
-            className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
+            className='emojione'
             alt='🙂'
-            src='/emoji/1f602.svg'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />
+        </div>
+
+        <Overlay show={active} placement='bottom' target={this.findTarget}>
+          <EmojiPickerMenu
+            custom_emojis={this.props.custom_emojis}
+            onClose={this.onHideDropdown}
+            onPick={onPickEmoji}
           />
-        </DropdownTrigger>
-
-        <DropdownContent className='dropdown__left'>
-          {
-            this.state.active && !this.state.loading &&
-            (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />)
-          }
-        </DropdownContent>
-      </Dropdown>
+        </Overlay>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
index 5000ea2f1..7f346854c 100644
--- a/app/javascript/mastodon/features/compose/components/navigation_bar.js
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -19,7 +19,7 @@ export default class NavigationBar extends ImmutablePureComponent {
       <div className='navigation-bar'>
         <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
           <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
-          <Avatar src={this.props.account.get('avatar')} staticSrc={this.props.account.get('avatar_static')} size={40} />
+          <Avatar account={this.props.account} size={40} />
         </Permalink>
 
         <div className='navigation-bar__profile'>
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index da3c0a0ab..0474dfb4e 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { injectIntl, defineMessages } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import detectPassiveEvents from 'detect-passive-events';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@@ -89,12 +90,12 @@ export default class PrivacyDropdown extends React.PureComponent {
 
   componentDidMount () {
     window.addEventListener('click', this.onGlobalClick);
-    window.addEventListener('touchstart', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
   }
 
   componentWillUnmount () {
     window.removeEventListener('click', this.onGlobalClick);
-    window.removeEventListener('touchstart', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
   }
 
   setRef = (c) => {
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
index da00e46c5..7672440b4 100644
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.js
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import Avatar from '../../../components/avatar';
 import IconButton from '../../../components/icon_button';
 import DisplayName from '../../../components/display_name';
-import emojify from '../../../emoji';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -43,7 +42,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
       return null;
     }
 
-    const content  = { __html: emojify(status.get('content')) };
+    const content  = { __html: status.get('contentHtml') };
 
     return (
       <div className='reply-indicator'>
@@ -51,7 +50,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
           <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
 
           <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
-            <div className='reply-indicator__display-avatar'><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div>
+            <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
             <DisplayName account={status.get('account')} />
           </a>
         </div>
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
index 78473dab4..cf2d2658a 100644
--- a/app/javascript/mastodon/features/compose/components/upload_form.js
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -21,7 +21,7 @@ export default class UploadForm extends React.PureComponent {
   };
 
   onRemoveFile = (e) => {
-    const id = Number(e.currentTarget.parentElement.getAttribute('data-id'));
+    const id = e.currentTarget.parentElement.getAttribute('data-id');
     this.props.onRemoveFile(id);
   }
 
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 1911edbf9..ffa0a3442 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -1,6 +1,6 @@
 import { connect } from 'react-redux';
 import ComposeForm from '../components/compose_form';
-import { uploadCompose } from '../../../actions/compose';
+import { changeComposeVisibility, uploadCompose } from '../../../actions/compose';
 import {
   changeCompose,
   submitCompose,
@@ -25,6 +25,8 @@ const mapStateToProps = state => ({
   is_uploading: state.getIn(['compose', 'is_uploading']),
   me: state.getIn(['compose', 'me']),
   showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+  settings: state.get('local_settings'),
+  filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
 });
 
 const mapDispatchToProps = (dispatch) => ({
@@ -33,6 +35,10 @@ const mapDispatchToProps = (dispatch) => ({
     dispatch(changeCompose(text));
   },
 
+  onPrivacyChange (value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
   onSubmit () {
     dispatch(submitCompose());
   },
diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
new file mode 100644
index 000000000..7a8026bbc
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+
+const mapStateToProps = state => ({
+  custom_emojis: state.get('custom_emojis'),
+});
+
+export default connect(mapStateToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
index 6e7d11c63..35eab5976 100644
--- a/app/javascript/mastodon/features/compose/containers/warning_container.js
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -1,51 +1,23 @@
 import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
 import { connect } from 'react-redux';
 import Warning from '../components/warning';
-import { createSelector } from 'reselect';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
-import { OrderedSet } from 'immutable';
 
-const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
-
-const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
-  return OrderedSet(mentionedUsernamesWithDomains !== null ? mentionedUsernamesWithDomains.map(item => item.split('@')[2]) : []);
+const mapStateToProps = state => ({
+  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
 });
 
-const mapStateToProps = state => {
-  const mentionedUsernames = getMentionedUsernames(state);
-  const mentionedUsernamesWithDomains = getMentionedDomains(state);
-
-  return {
-    needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
-    mentionedDomains: mentionedUsernamesWithDomains,
-    needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
-  };
-};
-
-const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => {
+const WarningWrapper = ({ needsLockWarning }) => {
   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> }} />} />;
-  } else if (needsLeakWarning) {
-    return (
-      <Warning
-        message={<FormattedMessage
-          id='compose_form.privacy_disclaimer'
-          defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.'
-          values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.size }}
-        />}
-      />
-    );
   }
 
   return null;
 };
 
 WarningWrapper.propTypes = {
-  needsLeakWarning: PropTypes.bool,
   needsLockWarning: PropTypes.bool,
-  mentionedDomains: ImmutablePropTypes.orderedSet.isRequired,
 };
 
 export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js
index f0fea1a0e..588a372c6 100644
--- a/app/javascript/mastodon/features/compose/util/counter.js
+++ b/app/javascript/mastodon/features/compose/util/counter.js
@@ -1,7 +1,9 @@
+import { urlRegex } from './url_regex';
+
 const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
 
 export function countableText(inputText) {
   return inputText
-    .replace(/https?:\/\/\S+/g, urlPlaceholder)
+    .replace(urlRegex, urlPlaceholder)
     .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+)/ig, '@$2');
 };
diff --git a/app/javascript/mastodon/features/compose/util/url_regex.js b/app/javascript/mastodon/features/compose/util/url_regex.js
new file mode 100644
index 000000000..e676d1879
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/util/url_regex.js
@@ -0,0 +1,196 @@
+const regexen = {};
+
+const regexSupplant = function(regex, flags) {
+  flags = flags || '';
+  if (typeof regex !== 'string') {
+    if (regex.global && flags.indexOf('g') < 0) {
+      flags += 'g';
+    }
+    if (regex.ignoreCase && flags.indexOf('i') < 0) {
+      flags += 'i';
+    }
+    if (regex.multiline && flags.indexOf('m') < 0) {
+      flags += 'm';
+    }
+
+    regex = regex.source;
+  }
+  return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
+    var newRegex = regexen[name] || '';
+    if (typeof newRegex !== 'string') {
+      newRegex = newRegex.source;
+    }
+    return newRegex;
+  }), flags);
+};
+
+const stringSupplant = function(str, values) {
+  return str.replace(/#\{(\w+)\}/g, function(match, name) {
+    return values[name] || '';
+  });
+};
+
+export const urlRegex = (function() {
+  regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/;
+  regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
+  regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/;
+  regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/);
+  regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
+  regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
+  regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
+  regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
+  regexen.validGTLD = regexSupplant(RegExp(
+  '(?:(?:' +
+    '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
+    '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
+    'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
+    'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
+    'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
+    'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
+    'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
+    'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
+    'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
+    'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
+    'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
+    'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
+    'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
+    'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
+    'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
+    'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
+    'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
+    'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
+    'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
+    'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
+    'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
+    'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
+    'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
+    'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
+    'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
+    'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
+    'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
+    'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
+    'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
+    'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
+    'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
+    'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
+    'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
+    'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
+    'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
+    'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
+    'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
+    'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
+    'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
+    'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
+    'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
+    'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
+    'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
+    'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
+    'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
+    'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
+    'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
+    'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
+    'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
+    'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
+    'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
+    'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
+    'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
+    'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
+    'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
+    'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
+    'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
+    'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
+    'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
+    'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
+    'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
+    'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
+    'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
+    'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
+    'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
+    'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
+    'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
+    'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
+    'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
+    'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
+    'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
+    'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
+    'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
+    'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
+    'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
+    'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
+    'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
+    'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
+    'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
+    'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
+    'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
+    'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
+    'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
+    'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
+    'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
+    'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
+    'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
+    'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
+  ')(?=[^0-9a-zA-Z@]|$))'));
+  regexen.validCCTLD = regexSupplant(RegExp(
+  '(?:(?:' +
+      '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
+      'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
+      'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
+      'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
+      'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
+      're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
+      'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
+      'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
+      'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
+      'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
+      'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
+  ')(?=[^0-9a-zA-Z@]|$))'));
+  regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
+  regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
+  regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
+  regexen.validPortNumber = /[0-9]+/;
+  regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
+  regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i);
+  // Allow URL paths to contain up to two nested levels of balanced parens
+  //  1. Used in Wikipedia URLs like /Primer_(film)
+  //  2. Used in IIS sessions like /S(dfd346)/
+  //  3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
+  regexen.validUrlBalancedParens = regexSupplant(
+    '\\('                                   +
+      '(?:'                                 +
+        '#{validGeneralUrlPathChars}+'      +
+        '|'                                 +
+        // allow one nested level of balanced parentheses
+        '(?:'                               +
+          '#{validGeneralUrlPathChars}*'    +
+          '\\('                             +
+            '#{validGeneralUrlPathChars}+'  +
+          '\\)'                             +
+          '#{validGeneralUrlPathChars}*'    +
+        ')'                                 +
+      ')'                                   +
+    '\\)'
+  , 'i');
+  // Valid end-of-path chracters (so /foo. does not gobble the period).
+  // 1. Allow =&# for empty URL parameters and other URL-join artifacts
+  regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i);
+  // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
+  regexen.validUrlPath = regexSupplant('(?:' +
+    '(?:' +
+      '#{validGeneralUrlPathChars}*' +
+        '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
+        '#{validUrlPathEndingChars}'+
+      ')|(?:@#{validGeneralUrlPathChars}+\/)'+
+    ')', 'i');
+  regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i;
+  regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i;
+  regexen.validUrl = regexSupplant(
+    '('                                                          + // $1 URL
+      '(https?:\\/\\/)'                                          + // $2 Protocol
+      '(#{validDomain})'                                         + // $3 Domain(s)
+      '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional)
+      '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path
+      '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String
+    ')'
+  , 'gi');
+  return regexen.validUrl;
+}());
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
index a81e4de70..8135527c9 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -16,6 +16,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
 });
 
 @connect(mapStateToProps)
@@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
   };
 
   componentWillMount () {
@@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, statusIds, columnId, multiColumn } = this.props;
+    const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
     const pinned = !!columnId;
 
     return (
@@ -75,12 +77,14 @@ export default class Favourites extends ImmutablePureComponent {
           onClick={this.handleHeaderClick}
           pinned={pinned}
           multiColumn={multiColumn}
+          showBackButton
         />
 
         <StatusList
           trackScroll={!pinned}
           statusIds={statusIds}
           scrollKey={`favourited_statuses-${columnId}`}
+          hasMore={hasMore}
           onScrollToBottom={this.handleScrollToBottom}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index dc8109d16..4dbfefd87 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)]),
+  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
 });
 
 @connect(mapStateToProps)
@@ -24,12 +24,12 @@ export default class Favourites extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
+    this.props.dispatch(fetchFavourites(this.props.params.statusId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
+      this.props.dispatch(fetchFavourites(nextProps.params.statusId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
index 566953ddd..4fc5638d9 100644
--- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
@@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from '../../../components/permalink';
 import Avatar from '../../../components/avatar';
 import DisplayName from '../../../components/display_name';
-import emojify from '../../../emoji';
 import IconButton from '../../../components/icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -26,13 +25,13 @@ export default class AccountAuthorize extends ImmutablePureComponent {
 
   render () {
     const { intl, account, onAuthorize, onReject } = this.props;
-    const content = { __html: emojify(account.get('note')) };
+    const content = { __html: account.get('note_emojified') };
 
     return (
       <div className='account-authorize__wrapper'>
         <div className='account-authorize'>
           <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
-            <div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div>
+            <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
             <DisplayName account={account} />
           </Permalink>
 
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index 2d85b9cc0..89445559f 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']),
-  hasMore: !!state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'next']),
+  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
 });
 
 @connect(mapStateToProps)
@@ -32,14 +32,14 @@ export default class Followers extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowers(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
     }
   }
 
@@ -47,13 +47,13 @@ export default class Followers extends ImmutablePureComponent {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
 
     if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
-      this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+      this.props.dispatch(expandFollowers(this.props.params.accountId));
     }
   }
 
   handleLoadMore = (e) => {
     e.preventDefault();
-    this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+    this.props.dispatch(expandFollowers(this.props.params.accountId));
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index e4e2a4811..c34830276 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']),
-  hasMore: !!state.getIn(['user_lists', 'following', Number(props.params.accountId), 'next']),
+  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
 });
 
 @connect(mapStateToProps)
@@ -32,14 +32,14 @@ export default class Following extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowing(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
     }
   }
 
@@ -47,13 +47,13 @@ export default class Following extends ImmutablePureComponent {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
 
     if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
-      this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+      this.props.dispatch(expandFollowing(this.props.params.accountId));
     }
   }
 
   handleLoadMore = (e) => {
     e.preventDefault();
-    this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+    this.props.dispatch(expandFollowing(this.props.params.accountId));
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index e4d9262a0..68267c54f 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -25,6 +25,8 @@ const messages = defineMessages({
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
   info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
+  show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
 });
 
 const mapStateToProps = state => ({
@@ -48,6 +50,11 @@ export default class GettingStarted extends ImmutablePureComponent {
     this.props.dispatch(openModal('SETTINGS', {}));
   }
 
+  openOnboardingModal = (e) => {
+    e.preventDefault();
+    this.props.dispatch(openModal('ONBOARDING'));
+  }
+
   render () {
     const { intl, me, columns, multiColumn } = this.props;
 
@@ -73,15 +80,16 @@ export default class GettingStarted extends ImmutablePureComponent {
 
     navItems = navItems.concat([
       <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
+      <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
     ]);
 
     if (me.get('locked')) {
-      navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+      navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
     }
 
     navItems = navItems.concat([
-      <ColumnLink key='6' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
-      <ColumnLink key='7' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
+      <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
+      <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
     ]);
 
     return (
@@ -92,6 +100,7 @@ export default class GettingStarted extends ImmutablePureComponent {
             {navItems}
             <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
             <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} />
             <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
             <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} />
             <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
@@ -100,13 +109,24 @@ export default class GettingStarted extends ImmutablePureComponent {
           <div className='getting-started__footer'>
             <div className='static-content getting-started'>
               <p>
-                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a>
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.faq' defaultMessage='FAQ' />
+                </a>&nbsp;•&nbsp;
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' />
+                </a>&nbsp;•&nbsp;
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' />
+                </a>
               </p>
               <p>
                 <FormattedMessage
                   id='getting_started.open_source_notice'
                   defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
-                  values={{ github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
+                  values={{
+                    github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>,
+                    Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a>,
+                  }}
                 />
               </p>
             </div>
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index 89382bb14..2077b7cdf 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header';
 import {
   refreshHashtagTimeline,
   expandHashtagTimeline,
-  updateTimeline,
-  deleteFromTimelines,
 } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { FormattedMessage } from 'react-intl';
-import createStream from '../../stream';
+import { connectHashtagStream } from '../../actions/streaming';
 
-const mapStateToProps = state => ({
-  hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
-  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
-  accessToken: state.getIn(['meta', 'access_token']),
+const mapStateToProps = (state, props) => ({
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
 });
 
 @connect(mapStateToProps)
@@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent {
     params: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
-    streamingAPIBaseURL: PropTypes.string.isRequired,
-    accessToken: PropTypes.string.isRequired,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
   };
@@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent {
   }
 
   _subscribe (dispatch, id) {
-    const { streamingAPIBaseURL, accessToken } = this.props;
-
-    this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          dispatch(deleteFromTimelines(data.payload));
-          break;
-        }
-      },
-
-    });
+    this.disconnect = dispatch(connectHashtagStream(id));
   }
 
   _unsubscribe () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 0771849c2..b52c3c934 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -2,19 +2,19 @@
 //  SEE INSTEAD : glitch/components/notification
 
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import StatusContainer from '../../../containers/status_container';
 import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
-import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 export default class Notification extends ImmutablePureComponent {
 
   static propTypes = {
     notification: ImmutablePropTypes.map.isRequired,
+    hidden: PropTypes.bool,
   };
 
   renderFollow (account, link) {
@@ -28,13 +28,13 @@ export default class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
         </div>
 
-        <AccountContainer id={account.get('id')} withNote={false} />
+        <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
       </div>
     );
   }
 
   renderMention (notification) {
-    return <StatusContainer id={notification.get('status')} withDismiss />;
+    return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
   }
 
   renderFavourite (notification, link) {
@@ -47,7 +47,7 @@ export default class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
         </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
       </div>
     );
   }
@@ -62,7 +62,7 @@ export default class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
         </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
       </div>
     );
   }
@@ -70,9 +70,8 @@ export default class Notification extends ImmutablePureComponent {
   render () {
     const { notification } = this.props;
     const account          = notification.get('account');
-    const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
-    const displayNameHTML  = { __html: emojify(escapeTextContentForBrowser(displayName)) };
-    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
+    const displayNameHtml  = { __html: account.get('display_name_html') };
+    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} />;
 
     switch(notification.get('type')) {
     case 'follow':
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
index a20e7ca51..281359d2a 100644
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -18,12 +18,6 @@ export default class SettingToggle extends React.PureComponent {
     this.props.onChange(this.props.settingKey, target.checked);
   }
 
-  onKeyDown = e => {
-    if (e.key === ' ') {
-      this.props.onChange(this.props.settingKey, !e.target.checked);
-    }
-  }
-
   render () {
     const { prefix, settings, settingKey, label, meta } = this.props;
     const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 97c3f29ae..0ed940c6d 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -11,13 +11,12 @@ import {
 } from '../../actions/notifications';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import NotificationContainer from '../../../glitch/components/notification/container';
-import { ScrollContainer } from 'react-router-scroll';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 import { createSelector } from 'reselect';
 import { List as ImmutableList } from 'immutable';
-import LoadMore from '../../components/load_more';
 import { debounce } from 'lodash';
+import ScrollableList from '../../components/scrollable_list';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -68,40 +67,18 @@ export default class Notifications extends React.PureComponent {
     trackScroll: true,
   };
 
-  dispatchExpandNotifications = debounce(() => {
+  handleScrollToBottom = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
     this.props.dispatch(expandNotifications());
   }, 300, { leading: true });
 
-  dispatchScrollToTop = debounce((top) => {
-    this.props.dispatch(scrollTopNotifications(top));
+  handleScrollToTop = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(true));
   }, 100);
 
-  handleScroll = (e) => {
-    const { scrollTop, scrollHeight, clientHeight } = e.target;
-    const offset = scrollHeight - scrollTop - clientHeight;
-    this._oldScrollPosition = scrollHeight - scrollTop;
-
-    if (250 > offset && this.props.hasMore && !this.props.isLoading) {
-      this.dispatchExpandNotifications();
-    }
-
-    if (scrollTop < 100) {
-      this.dispatchScrollToTop(true);
-    } else {
-      this.dispatchScrollToTop(false);
-    }
-  }
-
-  componentDidUpdate (prevProps) {
-    if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) {
-      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
-    }
-  }
-
-  handleLoadMore = (e) => {
-    e.preventDefault();
-    this.dispatchExpandNotifications();
-  }
+  handleScroll = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
+  }, 100);
 
   handlePin = () => {
     const { columnId, dispatch } = this.props;
@@ -122,10 +99,6 @@ export default class Notifications extends React.PureComponent {
     this.column.scrollTop();
   }
 
-  setRef = (c) => {
-    this.node = c;
-  }
-
   setColumnRef = c => {
     this.column = c;
   }
@@ -133,52 +106,35 @@ export default class Notifications extends React.PureComponent {
   render () {
     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
     const pinned = !!columnId;
+    const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
 
-    let loadMore       = '';
-    let scrollableArea = '';
-    let unread         = '';
-    let scrollContainer = '';
-
-    if (!isLoading && hasMore) {
-      loadMore = <LoadMore onClick={this.handleLoadMore} />;
-    }
-
-    if (isUnread) {
-      unread = <div className='notifications__unread-indicator' />;
-    }
+    let scrollableContent = null;
 
-    if (isLoading && this.scrollableArea) {
-      scrollableArea = this.scrollableArea;
+    if (isLoading && this.scrollableContent) {
+      scrollableContent = this.scrollableContent;
     } else if (notifications.size > 0 || hasMore) {
-      scrollableArea = (
-        <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
-          {unread}
-
-          <div>
-            {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
-            {loadMore}
-          </div>
-        </div>
-      );
+      scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
     } else {
-      scrollableArea = (
-        <div className='empty-column-indicator' ref={this.setRef}>
-          <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
-        </div>
-      );
+      scrollableContent = null;
     }
 
-    if (pinned) {
-      scrollContainer = scrollableArea;
-    } else {
-      scrollContainer = (
-        <ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}>
-          {scrollableArea}
-        </ScrollContainer>
-      );
-    }
-
-    this.scrollableArea = scrollableArea;
+    this.scrollableContent = scrollableContent;
+
+    const scrollContainer = (
+      <ScrollableList
+        scrollKey={`notifications-${columnId}`}
+        trackScroll={!pinned}
+        isLoading={isLoading}
+        hasMore={hasMore}
+        emptyMessage={emptyMessage}
+        onScrollToBottom={this.handleScrollToBottom}
+        onScrollToTop={this.handleScrollToTop}
+        onScroll={this.handleScroll}
+        shouldUpdateScroll={shouldUpdateScroll}
+      >
+        {scrollableContent}
+      </ScrollableList>
+    );
 
     return (
       <Column
diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js
new file mode 100644
index 000000000..b4a6c1e52
--- /dev/null
+++ b/app/javascript/mastodon/features/pinned_statuses/index.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchPinnedStatuses } from '../../actions/pin_statuses';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import StatusList from '../../components/status_list';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.pins', defaultMessage: 'Pinned toot' },
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'pins', 'items']),
+  hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PinnedStatuses extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchPinnedStatuses());
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { intl, statusIds, hasMore } = this.props;
+
+    return (
+      <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
+        <ColumnBackButtonSlim />
+        <StatusList
+          statusIds={statusIds}
+          scrollKey='pinned_statuses'
+          hasMore={hasMore}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index 5d82b2d4f..1821bc448 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header';
 import {
   refreshPublicTimeline,
   expandPublicTimeline,
-  updateTimeline,
-  deleteFromTimelines,
-  connectTimeline,
-  disconnectTimeline,
 } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
-import createStream from '../../stream';
+import { connectPublicStream } from '../../actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'column.public', defaultMessage: 'Federated timeline' },
@@ -23,8 +19,6 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
-  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
-  accessToken: state.getIn(['meta', 'access_token']),
 });
 
 @connect(mapStateToProps)
@@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent {
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     multiColumn: PropTypes.bool,
-    streamingAPIBaseURL: PropTypes.string.isRequired,
-    accessToken: PropTypes.string.isRequired,
     hasUnread: PropTypes.bool,
   };
 
@@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent {
   }
 
   componentDidMount () {
-    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+    const { dispatch } = this.props;
 
     dispatch(refreshPublicTimeline());
-
-    if (typeof this._subscription !== 'undefined') {
-      return;
-    }
-
-    this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
-
-      connected () {
-        dispatch(connectTimeline('public'));
-      },
-
-      reconnected () {
-        dispatch(connectTimeline('public'));
-      },
-
-      disconnected () {
-        dispatch(disconnectTimeline('public'));
-      },
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          dispatch(updateTimeline('public', JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          dispatch(deleteFromTimelines(data.payload));
-          break;
-        }
-      },
-
-    });
+    this.disconnect = dispatch(connectPublicStream());
   }
 
   componentWillUnmount () {
-    if (typeof this._subscription !== 'undefined') {
-      this._subscription.close();
-      this._subscription = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
index dc940ae01..f1904786a 100644
--- a/app/javascript/mastodon/features/reblogs/index.js
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)]),
+  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
 });
 
 @connect(mapStateToProps)
@@ -24,12 +24,12 @@ export default class Reblogs extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
+    this.props.dispatch(fetchReblogs(this.props.params.statusId));
   }
 
   componentWillReceiveProps(nextProps) {
     if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
+      this.props.dispatch(fetchReblogs(nextProps.params.statusId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
index 6a1a84c28..cc9232201 100644
--- a/app/javascript/mastodon/features/report/components/status_check_box.js
+++ b/app/javascript/mastodon/features/report/components/status_check_box.js
@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import emojify from '../../../emoji';
 import Toggle from 'react-toggle';
 
 export default class StatusCheckBox extends React.PureComponent {
@@ -15,7 +14,7 @@ export default class StatusCheckBox extends React.PureComponent {
 
   render () {
     const { status, checked, onToggle, disabled } = this.props;
-    const content = { __html: emojify(status.get('content')) };
+    const content = { __html: status.get('contentHtml') };
 
     if (status.get('reblog')) {
       return null;
diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js
new file mode 100644
index 000000000..0d764575f
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/compose/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ComposeFormContainer from '../../compose/containers/compose_form_container';
+import NotificationsContainer from '../../ui/containers/notifications_container';
+import LoadingBarContainer from '../../ui/containers/loading_bar_container';
+import ModalContainer from '../../ui/containers/modal_container';
+
+export default class Compose extends React.PureComponent {
+
+  render () {
+    return (
+      <div>
+        <ComposeFormContainer />
+        <NotificationsContainer />
+        <ModalContainer />
+        <LoadingBarContainer className='loading-bar' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index a2885adda..3e94f7446 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -14,6 +14,9 @@ const messages = defineMessages({
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   share: { id: 'status.share', defaultMessage: 'Share' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
 });
 
 @injectIntl
@@ -31,7 +34,9 @@ export default class ActionBar extends React.PureComponent {
     onDelete: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
-    me: PropTypes.number.isRequired,
+    onPin: PropTypes.func,
+    onEmbed: PropTypes.func,
+    me: PropTypes.string.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
@@ -59,6 +64,10 @@ export default class ActionBar extends React.PureComponent {
     this.props.onReport(this.props.status);
   }
 
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
   handleShare = () => {
     navigator.share({
       text: this.props.status.get('search_index'),
@@ -66,12 +75,26 @@ export default class ActionBar extends React.PureComponent {
     });
   }
 
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
   render () {
     const { status, me, intl } = this.props;
 
+    const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
     let menu = [];
 
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
     if (me === status.getIn(['account', 'id'])) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
       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 });
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index bfb40468b..41c4300d3 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -1,6 +1,8 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import punycode from 'punycode';
+import classnames from 'classnames';
 
 const IDNA_PREFIX = 'xn--';
 
@@ -21,10 +23,15 @@ export default class Card extends React.PureComponent {
 
   static propTypes = {
     card: ImmutablePropTypes.map,
+    maxDescription: PropTypes.number,
+  };
+
+  static defaultProps = {
+    maxDescription: 50,
   };
 
   renderLink () {
-    const { card } = this.props;
+    const { card, maxDescription } = this.props;
 
     let image    = '';
     let provider = card.get('provider_name');
@@ -32,7 +39,7 @@ export default class Card extends React.PureComponent {
     if (card.get('image')) {
       image = (
         <div className='status-card__image'>
-          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' />
+          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} />
         </div>
       );
     }
@@ -41,13 +48,17 @@ export default class Card extends React.PureComponent {
       provider = decodeIDNA(getHostname(card.get('url')));
     }
 
+    const className = classnames('status-card', {
+      'horizontal': card.get('width') > card.get('height'),
+    });
+
     return (
-      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
+      <a href={card.get('url')} className={className} target='_blank' rel='noopener'>
         {image}
 
         <div className='status-card__content'>
           <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
-          <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p>
+          <p className='status-card__description'>{(card.get('description') || '').substring(0, maxDescription)}</p>
           <span className='status-card__host'>{provider}</span>
         </div>
       </a>
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 5d28d4390..8cd5abd3f 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -11,6 +11,7 @@ import Link from 'react-router-dom/Link';
 import { FormattedDate, FormattedNumber } from 'react-intl';
 import CardContainer from '../containers/card_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from '../../video';
 import VisibilityIcon from '../../../../glitch/components/status/visibility_icon';
 
 export default class DetailedStatus extends ImmutablePureComponent {
@@ -36,6 +37,10 @@ export default class DetailedStatus extends ImmutablePureComponent {
     e.stopPropagation();
   }
 
+  handleOpenVideo = startTime => {
+    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  }
+
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
     const { settings } = this.props;
@@ -53,6 +58,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             sensitive={status.get('sensitive')}
             media={status.getIn(['media_attachments', 0])}
             letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
             height={250}
             onOpenVideo={this.props.onOpenVideo}
             autoplay
@@ -65,6 +71,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             sensitive={status.get('sensitive')}
             media={status.get('media_attachments')}
             letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
             height={250}
             onOpenMedia={this.props.onOpenMedia}
             autoPlayGif={this.props.autoPlayGif}
@@ -81,7 +88,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     return (
       <div className='detailed-status'>
         <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
-          <div className='detailed-status__display-avatar'><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
+          <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
           <DisplayName account={status.get('account')} />
         </a>
 
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index d774dfdfe..fc45d5f21 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -12,6 +12,8 @@ import {
   unfavourite,
   reblog,
   unreblog,
+  pin,
+  unpin,
 } from '../../actions/interactions';
 import {
   replyCompose,
@@ -36,10 +38,10 @@ const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
   const mapStateToProps = (state, props) => ({
-    status: getStatus(state, Number(props.params.statusId)),
+    status: getStatus(state, props.params.statusId),
     settings: state.get('local_settings'),
-    ancestorsIds: state.getIn(['contexts', 'ancestors', Number(props.params.statusId)]),
-    descendantsIds: state.getIn(['contexts', 'descendants', Number(props.params.statusId)]),
+    ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
+    descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
     me: state.getIn(['meta', 'me']),
     boostModal: state.getIn(['meta', 'boost_modal']),
     deleteModal: state.getIn(['meta', 'delete_modal']),
@@ -64,7 +66,7 @@ export default class Status extends ImmutablePureComponent {
     settings: ImmutablePropTypes.map.isRequired,
     ancestorsIds: ImmutablePropTypes.list,
     descendantsIds: ImmutablePropTypes.list,
-    me: PropTypes.number,
+    me: PropTypes.string,
     boostModal: PropTypes.bool,
     deleteModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
@@ -72,12 +74,12 @@ export default class Status extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchStatus(Number(this.props.params.statusId)));
+    this.props.dispatch(fetchStatus(this.props.params.statusId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this.props.dispatch(fetchStatus(Number(nextProps.params.statusId)));
+      this.props.dispatch(fetchStatus(nextProps.params.statusId));
     }
   }
 
@@ -89,6 +91,14 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handlePin = (status) => {
+    if (status.get('pinned')) {
+      this.props.dispatch(unpin(status));
+    } else {
+      this.props.dispatch(pin(status));
+    }
+  }
+
   handleReplyClick = (status) => {
     this.props.dispatch(replyCompose(status, this.context.router.history));
   }
@@ -139,6 +149,10 @@ export default class Status extends ImmutablePureComponent {
     this.props.dispatch(initReport(status.get('account'), status));
   }
 
+  handleEmbed = (status) => {
+    this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+  }
+
   renderChildren (list) {
     return list.map(id => <StatusContainer key={id} id={id} />);
   }
@@ -190,6 +204,8 @@ export default class Status extends ImmutablePureComponent {
               onDelete={this.handleDeleteClick}
               onMention={this.handleMentionClick}
               onReport={this.handleReport}
+              onPin={this.handlePin}
+              onEmbed={this.handleEmbed}
             />
 
             {descendants}
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
index cc0620d1c..79a5a20ef 100644
--- a/app/javascript/mastodon/features/ui/components/actions_modal.js
+++ b/app/javascript/mastodon/features/ui/components/actions_modal.js
@@ -1,32 +1,35 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import StatusContent from '../../../components/status_content';
 import Avatar from '../../../components/avatar';
 import RelativeTimestamp from '../../../components/relative_timestamp';
 import DisplayName from '../../../components/display_name';
 import IconButton from '../../../components/icon_button';
+import classNames from 'classnames';
 
 export default class ActionsModal extends ImmutablePureComponent {
 
   static propTypes = {
+    status: ImmutablePropTypes.map,
     actions: PropTypes.array,
     onClick: PropTypes.func,
   };
 
   renderAction = (action, i) => {
     if (action === null) {
-      return <li key={`sep-${i}`} className='dropdown__sep' />;
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
     }
 
     const { icon = null, text, meta = null, active = false, href = '#' } = action;
 
     return (
       <li key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}>
+        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
           {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
           <div>
-            <div>{text}</div>
+            <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
             <div>{meta}</div>
           </div>
         </a>
@@ -46,7 +49,7 @@ export default class ActionsModal extends ImmutablePureComponent {
 
           <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'>
             <div className='status__avatar'>
-              <Avatar src={this.props.status.getIn(['account', 'avatar'])} staticSrc={this.props.status.getIn(['account', 'avatar_static'])} size={48} />
+              <Avatar account={this.props.status.get('account')} size={48} />
             </div>
 
             <DisplayName account={this.props.status.get('account')} />
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
index a1b0cf4bd..dfd1284e9 100644
--- a/app/javascript/mastodon/features/ui/components/boost_modal.js
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -62,7 +62,7 @@ export default class BoostModal extends ImmutablePureComponent {
 
               <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
                 <div className='status__avatar'>
-                  <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
+                  <Avatar account={status.get('account')} size={48} />
                 </div>
 
                 <DisplayName account={status.get('account')} />
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
index 610a3cc25..c1700f86e 100644
--- a/app/javascript/mastodon/features/ui/components/column.js
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ColumnHeader from './column_header';
 import PropTypes from 'prop-types';
 import { debounce } from 'lodash';
-import scrollTop from '../../../scroll';
+import { scrollTop } from '../../../scroll';
 import { isMobile } from '../../../is_mobile';
 
 export default class Column extends React.PureComponent {
@@ -26,6 +26,17 @@ export default class Column extends React.PureComponent {
     this._interruptScrollAnimation = scrollTop(scrollable);
   }
 
+  scrollTop () {
+    const scrollable = this.node.querySelector('.scrollable');
+
+    if (!scrollable) {
+      return;
+    }
+
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+
   handleScroll = debounce(() => {
     if (typeof this._interruptScrollAnimation !== 'undefined') {
       this._interruptScrollAnimation();
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js
index 1c4058926..9503a7a1a 100644
--- a/app/javascript/mastodon/features/ui/components/column_loading.js
+++ b/app/javascript/mastodon/features/ui/components/column_loading.js
@@ -3,17 +3,28 @@ import PropTypes from 'prop-types';
 
 import Column from '../../../components/column';
 import ColumnHeader from '../../../components/column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
 
-const ColumnLoading = ({ title = '', icon = ' ' }) => (
-  <Column>
-    <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
-    <div className='scrollable' />
-  </Column>
-);
+export default class ColumnLoading extends ImmutablePureComponent {
 
-ColumnLoading.propTypes = {
-  title: PropTypes.node,
-  icon: PropTypes.string,
-};
+  static propTypes = {
+    title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+    icon: PropTypes.string,
+  };
 
-export default ColumnLoading;
+  static defaultProps = {
+    title: '',
+    icon: '',
+  };
+
+  render() {
+    let { title, icon } = this.props;
+    return (
+      <Column>
+        <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
+        <div className='scrollable' />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 63bd1b021..5610095b9 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -9,9 +9,13 @@ import { links, getIndex, getLink } from './tabs_bar';
 
 import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
+import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
 import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
 
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollRight } from '../../../scroll';
+
 const componentMap = {
   'COMPOSE': Compose,
   'HOME': HomeTimeline,
@@ -22,7 +26,7 @@ const componentMap = {
   'FAVOURITES': FavouritedStatuses,
 };
 
-@injectIntl
+@component => injectIntl(component, { withRef: true })
 export default class ColumnsArea extends ImmutablePureComponent {
 
   static contextTypes = {
@@ -45,15 +49,39 @@ export default class ColumnsArea extends ImmutablePureComponent {
   }
 
   componentDidMount() {
+    if (!this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+    }
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
   }
 
-  componentDidUpdate() {
+  componentWillUpdate(nextProps) {
+    if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+    }
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
   }
 
+  componentWillUnmount () {
+    if (!this.props.singleColumn) {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  handleChildrenContentChange() {
+    if (!this.props.singleColumn) {
+      this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
+    }
+  }
+
   handleSwipe = (index) => {
     this.pendingIndex = index;
 
@@ -74,6 +102,18 @@ export default class ColumnsArea extends ImmutablePureComponent {
     }
   }
 
+  handleWheel = () => {
+    if (typeof this._interruptScrollAnimation !== 'function') {
+      return;
+    }
+
+    this._interruptScrollAnimation();
+  }
+
+  setRef = (node) => {
+    this.node = node;
+  }
+
   renderView = (link, index) => {
     const columnIndex = getIndex(this.context.router.history.location.pathname);
     const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
@@ -90,8 +130,8 @@ export default class ColumnsArea extends ImmutablePureComponent {
     );
   }
 
-  renderLoading = () => {
-    return <ColumnLoading />;
+  renderLoading = columnId => () => {
+    return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
   }
 
   renderError = (props) => {
@@ -114,12 +154,12 @@ export default class ColumnsArea extends ImmutablePureComponent {
     }
 
     return (
-      <div className='columns-area'>
+      <div className='columns-area' ref={this.setRef}>
         {columns.map(column => {
           const params = column.get('params', null) === null ? null : column.get('params').toJS();
 
           return (
-            <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading} error={this.renderError}>
+            <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 />}
             </BundleContainer>
           );
diff --git a/app/javascript/mastodon/features/ui/components/drawer_loading.js b/app/javascript/mastodon/features/ui/components/drawer_loading.js
new file mode 100644
index 000000000..08b0d2347
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/drawer_loading.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const DrawerLoading = () => (
+  <div className='drawer'>
+    <div className='drawer__pager'>
+      <div className='drawer__inner' />
+    </div>
+  </div>
+);
+
+export default DrawerLoading;
diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js
new file mode 100644
index 000000000..1afffb51b
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/embed_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import axios from 'axios';
+
+@injectIntl
+export default class EmbedModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    url: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  }
+
+  state = {
+    loading: false,
+    oembed: null,
+  };
+
+  componentDidMount () {
+    const { url } = this.props;
+
+    this.setState({ loading: true });
+
+    axios.post('/api/web/embed', { url }).then(res => {
+      this.setState({ loading: false, oembed: res.data });
+
+      const iframeDocument = this.iframe.contentWindow.document;
+
+      iframeDocument.open();
+      iframeDocument.write(res.data.html);
+      iframeDocument.close();
+
+      iframeDocument.body.style.margin = 0;
+      this.iframe.width  = iframeDocument.body.scrollWidth;
+      this.iframe.height = iframeDocument.body.scrollHeight;
+    });
+  }
+
+  setIframeRef = c =>  {
+    this.iframe = c;
+  }
+
+  handleTextareaClick = (e) => {
+    e.target.select();
+  }
+
+  render () {
+    const { oembed } = this.state;
+
+    return (
+      <div className='modal-root__modal embed-modal'>
+        <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4>
+
+        <div className='embed-modal__container'>
+          <p className='hint'>
+            <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
+          </p>
+
+          <input
+            type='text'
+            className='embed-modal__html'
+            readOnly
+            value={oembed && oembed.html || ''}
+            onClick={this.handleTextareaClick}
+          />
+
+          <p className='hint'>
+            <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
+          </p>
+
+          <iframe
+            className='embed-modal__iframe'
+            frameBorder='0'
+            ref={this.setIframeRef}
+            title='preview'
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index d316ff433..6347c4b22 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -5,26 +5,30 @@ import spring from 'react-motion/lib/spring';
 import BundleContainer from '../containers/bundle_container';
 import BundleModalError from './bundle_modal_error';
 import ModalLoading from './modal_loading';
-import ActionsModal from '../components/actions_modal';
+import ActionsModal from './actions_modal';
+import MediaModal from './media_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import ConfirmationModal from './confirmation_modal';
 import {
-  MediaModal,
   OnboardingModal,
-  VideoModal,
-  BoostModal,
-  ConfirmationModal,
+  MuteModal,
   ReportModal,
   SettingsModal,
+  EmbedModal,
 } from '../../../features/ui/util/async-components';
 
 const MODAL_COMPONENTS = {
-  'MEDIA': MediaModal,
+  'MEDIA': () => Promise.resolve({ default: MediaModal }),
   'ONBOARDING': OnboardingModal,
-  'VIDEO': VideoModal,
-  'BOOST': BoostModal,
-  'CONFIRM': ConfirmationModal,
+  'VIDEO': () => Promise.resolve({ default: VideoModal }),
+  'BOOST': () => Promise.resolve({ default: BoostModal }),
+  'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+  'MUTE': MuteModal,
   'REPORT': ReportModal,
   'SETTINGS': SettingsModal,
   'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+  'EMBED': EmbedModal,
 };
 
 export default class ModalRoot extends React.PureComponent {
@@ -82,8 +86,8 @@ export default class ModalRoot extends React.PureComponent {
     return { opacity: spring(0), scale: spring(0.98) };
   }
 
-  renderLoading = () => {
-    return <ModalLoading />;
+  renderLoading = modalId => () => {
+    return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
   }
 
   renderError = (props) => {
@@ -117,7 +121,7 @@ export default class ModalRoot extends React.PureComponent {
               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
                 <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
                 <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
-                  <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
+                  <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
                     {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
                   </BundleContainer>
                 </div>
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js
new file mode 100644
index 000000000..b5e83bb71
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/mute_modal.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+import { closeModal } from '../../../actions/modal';
+import { muteAccount } from '../../../actions/accounts';
+import { toggleHideNotifications } from '../../../actions/mutes';
+
+
+const mapStateToProps = state => {
+  return {
+    isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+    account: state.getIn(['mutes', 'new', 'account']),
+    notifications: state.getIn(['mutes', 'new', 'notifications']),
+  };
+};
+
+const mapDispatchToProps = dispatch => {
+  return {
+    onConfirm(account, notifications) {
+      dispatch(muteAccount(account.get('id'), notifications));
+    },
+
+    onClose() {
+      dispatch(closeModal());
+    },
+
+    onToggleNotifications() {
+      dispatch(toggleHideNotifications());
+    },
+  };
+};
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class MuteModal extends React.PureComponent {
+
+  static propTypes = {
+    isSubmitting: PropTypes.bool.isRequired,
+    account: PropTypes.object.isRequired,
+    notifications: PropTypes.bool.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    onToggleNotifications: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm(this.props.account, this.props.notifications);
+  }
+
+  handleCancel = () => {
+    this.props.onClose();
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  toggleNotifications = () => {
+    this.props.onToggleNotifications();
+  }
+
+  render () {
+    const { account, notifications } = this.props;
+
+    return (
+      <div className='modal-root__modal mute-modal'>
+        <div className='mute-modal__container'>
+          <p>
+            <FormattedMessage
+              id='confirmations.mute.message'
+              defaultMessage='Are you sure you want to mute {name}?'
+              values={{ name: <strong>@{account.get('acct')}</strong> }}
+            />
+          </p>
+          <p>
+            <label htmlFor='mute-modal__hide-notifications-checkbox'>
+              <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
+              <input id='mute-modal__hide-notifications-checkbox' type='checkbox' checked={notifications} onChange={this.toggleNotifications} />
+            </label>
+          </p>
+        </div>
+
+        <div className='mute-modal__action-bar'>
+          <Button onClick={this.handleCancel} className='mute-modal__cancel-button'>
+            <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+          </Button>
+          <Button onClick={this.handleClick} ref={this.setRef}>
+            <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
index 1b1cb00da..daf6b485c 100644
--- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -10,7 +10,10 @@ import ComposeForm from '../../compose/components/compose_form';
 import Search from '../../compose/components/search';
 import NavigationBar from '../../compose/components/navigation_bar';
 import ColumnHeader from './column_header';
-import { List as ImmutableList } from 'immutable';
+import {
+  List as ImmutableList,
+  Map as ImmutableMap,
+} from 'immutable';
 
 const noop = () => { };
 
@@ -30,7 +33,7 @@ const PageOne = ({ acct, domain }) => (
     <div>
       <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1>
       <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p>
-      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p>
+      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p>
     </div>
   </div>
 );
@@ -44,7 +47,7 @@ const PageTwo = ({ me }) => (
   <div className='onboarding-modal__page onboarding-modal__page-two'>
     <div className='figure non-interactive'>
       <div className='pseudo-drawer'>
-        <NavigationBar account={me} />
+        <NavigationBar onClose={noop} account={me} />
       </div>
       <ComposeForm
         text='Awoo! #introductions'
@@ -59,7 +62,9 @@ const PageTwo = ({ me }) => (
         onClearSuggestions={noop}
         onFetchSuggestions={noop}
         onSuggestionSelected={noop}
+        onPrivacyChange={noop}
         showSearch
+        settings={ImmutableMap.of('side_arm', 'none')}
       />
     </div>
 
@@ -83,7 +88,7 @@ const PageThree = ({ me }) => (
       />
 
       <div className='pseudo-drawer'>
-        <NavigationBar account={me} />
+        <NavigationBar onClose={noop} account={me} />
       </div>
     </div>
 
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
index 030c3db2e..dda28feeb 100644
--- a/app/javascript/mastodon/features/ui/components/upload_area.js
+++ b/app/javascript/mastodon/features/ui/components/upload_area.js
@@ -12,13 +12,12 @@ export default class UploadArea extends React.PureComponent {
   };
 
   handleKeyUp = (e) => {
-    e.preventDefault();
-    e.stopPropagation();
-
     const keyCode = e.keyCode;
     if (this.props.active) {
       switch(keyCode) {
       case 27:
+        e.preventDefault();
+        e.stopPropagation();
         this.props.onClose();
         break;
       }
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
index 9a9a49dfb..867c73ed5 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -1,35 +1,29 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import ExtendedVideoPlayer from '../../../components/extended_video_player';
-import { defineMessages, injectIntl } from 'react-intl';
-import IconButton from '../../../components/icon_button';
+import Video from '../../video';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
-const messages = defineMessages({
-  close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-@injectIntl
 export default class VideoModal extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
     time: PropTypes.number,
     onClose: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
   };
 
   render () {
-    const { media, intl, time, onClose } = this.props;
-
-    const url = media.get('url');
+    const { media, time, onClose } = this.props;
 
     return (
       <div className='modal-root__modal media-modal'>
         <div>
-          <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div>
-          <ExtendedVideoPlayer src={url} muted={false} controls time={time} />
+          <Video
+            preview={media.get('preview_url')}
+            src={media.get('url')}
+            startTime={time}
+            onCloseVideo={onClose}
+          />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
index 6420f0784..95f95618b 100644
--- a/app/javascript/mastodon/features/ui/containers/columns_area_container.js
+++ b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
@@ -5,4 +5,4 @@ const mapStateToProps = state => ({
   columns: state.getIn(['settings', 'columns']),
 });
 
-export default connect(mapStateToProps)(ColumnsArea);
+export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index f7a6eb319..73bd23432 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -1,20 +1,21 @@
 import React from 'react';
-import classNames from 'classnames';
-import Redirect from 'react-router-dom/Redirect';
 import NotificationsContainer from './containers/notifications_container';
 import PropTypes from 'prop-types';
 import LoadingBarContainer from './containers/loading_bar_container';
 import TabsBar from './components/tabs_bar';
 import ModalContainer from './containers/modal_container';
 import { connect } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from '../../is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose } from '../../actions/compose';
 import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
+import { clearHeight } from '../../actions/height_cache';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
+import classNames from 'classnames';
 import {
   Compose,
   Status,
@@ -35,6 +36,7 @@ import {
   FavouritedStatuses,
   Blocks,
   Mutes,
+  PinnedStatuses,
 } from './util/async-components';
 
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
@@ -50,11 +52,12 @@ const mapStateToProps = state => ({
 });
 
 @connect(mapStateToProps)
+@withRouter
 export default class UI extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object.isRequired,
-  }
+  };
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
@@ -64,6 +67,7 @@ export default class UI extends React.PureComponent {
     systemFontUi: PropTypes.bool,
     navbarUnder: PropTypes.bool,
     isComposing: PropTypes.bool,
+    location: PropTypes.object,
   };
 
   state = {
@@ -72,6 +76,9 @@ export default class UI extends React.PureComponent {
   };
 
   handleResize = debounce(() => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.dispatch(clearHeight());
+
     this.setState({ width: window.innerWidth });
   }, 500, {
     trailing: true,
@@ -137,7 +144,7 @@ export default class UI extends React.PureComponent {
     if (data.type === 'navigate') {
       this.context.router.history.push(data.path);
     } else {
-      console.warn('Unknown message type:', data.type); // eslint-disable-line no-console
+      console.warn('Unknown message type:', data.type);
     }
   }
 
@@ -171,6 +178,12 @@ export default class UI extends React.PureComponent {
     return true;
   }
 
+  componentDidUpdate (prevProps) {
+    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+      this.columnsAreaNode.handleChildrenContentChange();
+    }
+  }
+
   componentWillUnmount () {
     window.removeEventListener('resize', this.handleResize);
     document.removeEventListener('dragenter', this.handleDragEnter);
@@ -180,10 +193,18 @@ export default class UI extends React.PureComponent {
     document.removeEventListener('dragend', this.handleDragEnd);
   }
 
-  setRef = (c) => {
+  setRef = c => {
     this.node = c;
   }
 
+  setColumnsAreaRef = c => {
+    this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
+  }
+
+  setOverlayRef = c => {
+    this.overlay = c;
+  }
+
   render () {
     const { width, draggingOver } = this.state;
     const { children, layout, isWide, navbarUnder } = this.props;
@@ -208,7 +229,7 @@ export default class UI extends React.PureComponent {
     return (
       <div className={className} ref={this.setRef}>
         {navbarUnder ? null : (<TabsBar />)}
-        <ColumnsAreaContainer singleColumn={isMobile(width, layout)}>
+        <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
           <WrappedSwitch>
             <Redirect from='/' to='/getting-started' exact />
             <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
@@ -219,6 +240,7 @@ export default class UI extends React.PureComponent {
 
             <WrappedRoute path='/notifications' component={Notifications} content={children} />
             <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+            <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
             <WrappedRoute path='/statuses/new' component={Compose} content={children} />
             <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 9267519dd..5d640810f 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -1,7 +1,3 @@
-export function EmojiPicker () {
-  return import(/* webpackChunkName: "emojione_picker" */'emojione-picker');
-}
-
 export function Compose () {
   return import(/* webpackChunkName: "features/compose" */'../../compose');
 }
@@ -34,6 +30,10 @@ export function GettingStarted () {
   return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
 }
 
+export function PinnedStatuses () {
+  return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
+}
+
 export function AccountTimeline () {
   return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
 }
@@ -78,24 +78,12 @@ export function Mutes () {
   return import(/* webpackChunkName: "features/mutes" */'../../mutes');
 }
 
-export function MediaModal () {
-  return import(/* webpackChunkName: "modals/media_modal" */'../components/media_modal');
-}
-
 export function OnboardingModal () {
   return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
 }
 
-export function VideoModal () {
-  return import(/* webpackChunkName: "modals/video_modal" */'../components/video_modal');
-}
-
-export function BoostModal () {
-  return import(/* webpackChunkName: "modals/boost_modal" */'../components/boost_modal');
-}
-
-export function ConfirmationModal () {
-  return import(/* webpackChunkName: "modals/confirmation_modal" */'../components/confirmation_modal');
+export function MuteModal () {
+  return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
 }
 
 export function ReportModal () {
@@ -116,3 +104,11 @@ export function MediaGallery () {
 export function VideoPlayer () {
   return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
 }
+
+export function Video () {
+  return import(/* webpackChunkName: "features/video" */'../../video');
+}
+
+export function EmbedModal () {
+  return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
+}
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
new file mode 100644
index 000000000..f228e434b
--- /dev/null
+++ b/app/javascript/mastodon/features/video/index.js
@@ -0,0 +1,304 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+  hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+  expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+  close: { id: 'video.close', defaultMessage: 'Close video' },
+  fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+  exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+const findElementPosition = el => {
+  let box;
+
+  if (el.getBoundingClientRect && el.parentNode) {
+    box = el.getBoundingClientRect();
+  }
+
+  if (!box) {
+    return {
+      left: 0,
+      top: 0,
+    };
+  }
+
+  const docEl = document.documentElement;
+  const body  = document.body;
+
+  const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+  const scrollLeft = window.pageXOffset || body.scrollLeft;
+  const left       = (box.left + scrollLeft) - clientLeft;
+
+  const clientTop = docEl.clientTop || body.clientTop || 0;
+  const scrollTop = window.pageYOffset || body.scrollTop;
+  const top       = (box.top + scrollTop) - clientTop;
+
+  return {
+    left: Math.round(left),
+    top: Math.round(top),
+  };
+};
+
+const getPointerPosition = (el, event) => {
+  const position = {};
+  const box = findElementPosition(el);
+  const boxW = el.offsetWidth;
+  const boxH = el.offsetHeight;
+  const boxY = box.top;
+  const boxX = box.left;
+
+  let pageY = event.pageY;
+  let pageX = event.pageX;
+
+  if (event.changedTouches) {
+    pageX = event.changedTouches[0].pageX;
+    pageY = event.changedTouches[0].pageY;
+  }
+
+  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+  return position;
+};
+
+const isFullscreen = () => document.fullscreenElement ||
+  document.webkitFullscreenElement ||
+  document.mozFullScreenElement ||
+  document.msFullscreenElement;
+
+const exitFullscreen = () => {
+  if (document.exitFullscreen) {
+    document.exitFullscreen();
+  } else if (document.webkitExitFullscreen) {
+    document.webkitExitFullscreen();
+  } else if (document.mozCancelFullScreen) {
+    document.mozCancelFullScreen();
+  } else if (document.msExitFullscreen) {
+    document.msExitFullscreen();
+  }
+};
+
+const requestFullscreen = el => {
+  if (el.requestFullscreen) {
+    el.requestFullscreen();
+  } else if (el.webkitRequestFullscreen) {
+    el.webkitRequestFullscreen();
+  } else if (el.mozRequestFullScreen) {
+    el.mozRequestFullScreen();
+  } else if (el.msRequestFullscreen) {
+    el.msRequestFullscreen();
+  }
+};
+
+@injectIntl
+export default class Video extends React.PureComponent {
+
+  static propTypes = {
+    preview: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    sensitive: PropTypes.bool,
+    startTime: PropTypes.number,
+    onOpenVideo: PropTypes.func,
+    onCloseVideo: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    progress: 0,
+    paused: true,
+    dragging: false,
+    fullscreen: false,
+    hovered: false,
+    muted: false,
+    revealed: !this.props.sensitive,
+  };
+
+  setPlayerRef = c => {
+    this.player = c;
+  }
+
+  setVideoRef = c => {
+    this.video = c;
+  }
+
+  setSeekRef = c => {
+    this.seek = c;
+  }
+
+  handlePlay = () => {
+    this.setState({ paused: false });
+  }
+
+  handlePause = () => {
+    this.setState({ paused: true });
+  }
+
+  handleTimeUpdate = () => {
+    this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) });
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove, true);
+    document.addEventListener('mouseup', this.handleMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseMove, true);
+    document.addEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: true });
+    this.video.pause();
+    this.handleMouseMove(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove, true);
+    document.removeEventListener('mouseup', this.handleMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseMove, true);
+    document.removeEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: false });
+    this.video.play();
+  }
+
+  handleMouseMove = throttle(e => {
+    const { x } = getPointerPosition(this.seek, e);
+    this.video.currentTime = this.video.duration * x;
+    this.setState({ progress: x * 100 });
+  }, 60);
+
+  togglePlay = () => {
+    if (this.state.paused) {
+      this.video.play();
+    } else {
+      this.video.pause();
+    }
+  }
+
+  toggleFullscreen = () => {
+    if (isFullscreen()) {
+      exitFullscreen();
+    } else {
+      requestFullscreen(this.player);
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+  }
+
+  handleFullscreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  toggleMute = () => {
+    this.video.muted = !this.video.muted;
+    this.setState({ muted: this.video.muted });
+  }
+
+  toggleReveal = () => {
+    if (this.state.revealed) {
+      this.video.pause();
+    }
+
+    this.setState({ revealed: !this.state.revealed });
+  }
+
+  handleLoadedData = () => {
+    if (this.props.startTime) {
+      this.video.currentTime = this.props.startTime;
+      this.video.play();
+    }
+  }
+
+  handleOpenVideo = () => {
+    this.video.pause();
+    this.props.onOpenVideo(this.video.currentTime);
+  }
+
+  handleCloseVideo = () => {
+    this.video.pause();
+    this.props.onCloseVideo();
+  }
+
+  render () {
+    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props;
+    const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+
+    return (
+      <div className={classNames('video-player', { inactive: !revealed, inline: width && height && !fullscreen, fullscreen })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <video
+          ref={this.setVideoRef}
+          src={src}
+          poster={preview}
+          preload={!!startTime}
+          loop
+          role='button'
+          tabIndex='0'
+          width={width}
+          height={height}
+          onClick={this.togglePlay}
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onTimeUpdate={this.handleTimeUpdate}
+          onLoadedData={this.handleLoadedData}
+        />
+
+        <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
+          <span className='video-player__spoiler__title'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </button>
+
+        <div className={classNames('video-player__controls', { active: paused || hovered })}>
+          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+            <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
+
+            <span
+              className={classNames('video-player__seek__handle', { active: dragging })}
+              tabIndex='0'
+              style={{ left: `${progress}%` }}
+            />
+          </div>
+
+          <div className='video-player__buttons left'>
+            <button aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button>
+            <button aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
+            {!onCloseVideo && <button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>}
+          </div>
+
+          <div className='video-player__buttons right'>
+            {(!fullscreen && onOpenVideo) && <button aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
+            {onCloseVideo && <button aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-times' /></button>}
+            <button aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
index 129d66682..80e8e0a8a 100644
--- a/app/javascript/mastodon/is_mobile.js
+++ b/app/javascript/mastodon/is_mobile.js
@@ -1,4 +1,6 @@
-const LAYOUT_BREAKPOINT = 1024;
+import detectPassiveEvents from 'detect-passive-events';
+
+const LAYOUT_BREAKPOINT = 630;
 
 export function isMobile(width, columns) {
   switch (columns) {
@@ -12,11 +14,16 @@ export function isMobile(width, columns) {
 };
 
 const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
 let userTouching = false;
+let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
-window.addEventListener('touchstart', () => {
+function touchListener() {
   userTouching = true;
-}, { once: true });
+  window.removeEventListener('touchstart', touchListener, listenerOptions);
+}
+
+window.addEventListener('touchstart', touchListener, listenerOptions);
 
 export function isUserTouching() {
   return userTouching;
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index f5cf77f92..bd09f1970 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -33,6 +33,7 @@
   "column.home": "الرئيسية",
   "column.mutes": "الحسابات المكتومة",
   "column.notifications": "الإشعارات",
+  "column.pins": "Pinned toot",
   "column.public": "الخيط العام الموحد",
   "column_back_button.label": "العودة",
   "column_header.hide_settings": "Hide settings",
@@ -46,8 +47,7 @@
   "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
   "compose_form.lock_disclaimer.lock": "مقفل",
   "compose_form.placeholder": "فيمَ تفكّر؟",
-  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
-  "compose_form.publish": "بوّق !",
+  "compose_form.publish": "بوّق",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
   "compose_form.spoiler": "أخفِ النص واعرض تحذيرا",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {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": "الأنشطة",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "الأعلام",
   "emoji_button.food": "الطعام والشراب",
   "emoji_button.label": "أدرج إيموجي",
   "emoji_button.nature": "الطبيعة",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "أشياء",
   "emoji_button.people": "الناس",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "ابحث...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "رموز",
   "emoji_button.travel": "أماكن و أسفار",
   "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "معلومات إضافية",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "الحسابات المكتومة",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "التفضيلات",
   "navigation_bar.public_timeline": "الخيط العام الموحد",
   "notification.favourite": "{name} أعجب بمنشورك",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
   "status.delete": "إحذف",
+  "status.embed": "Embed",
   "status.favourite": "أضف إلى المفضلة",
   "status.load_more": "حمّل المزيد",
   "status.media_hidden": "الصورة مستترة",
   "status.mention": "أذكُر @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "وسع هذه المشاركة",
+  "status.pin": "Pin on profile",
   "status.reblog": "رَقِّي",
   "status.reblogged_by": "{name} رقى",
   "status.reply": "ردّ",
@@ -179,6 +188,7 @@
   "status.show_less": "إعرض أقلّ",
   "status.show_more": "أظهر المزيد",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "تحرير",
   "tabs_bar.federated_timeline": "الموحَّد",
   "tabs_bar.home": "الرئيسية",
@@ -188,6 +198,15 @@
   "upload_button.label": "إضافة وسائط",
   "upload_form.undo": "إلغاء",
   "upload_progress.label": "يرفع...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "وسّع الفيديو",
   "video_player.toggle_sound": "تبديل الصوت",
   "video_player.toggle_visible": "إظهار / إخفاء الفيديو",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index e6788f9eb..d391a57ba 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -33,6 +33,7 @@
   "column.home": "Начало",
   "column.mutes": "Muted users",
   "column.notifications": "Известия",
+  "column.pins": "Pinned toot",
   "column.public": "Публичен канал",
   "column_back_button.label": "Назад",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "Какво си мислиш?",
-  "compose_form.privacy_disclaimer": "Поверителни публикации ще бъдат изпратени до споменатите потребители на {domains}. Доверяваш ли се на {domainsCount, plural, one {that server} other {those servers}}, че няма да издаде твоята публикация?",
   "compose_form.publish": "Раздумай",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Отбележи съдържанието като деликатно",
@@ -63,14 +63,20 @@
   "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!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Extended information",
   "navigation_bar.logout": "Излизане",
   "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Предпочитания",
   "navigation_bar.public_timeline": "Публичен канал",
   "notification.favourite": "{name} хареса твоята публикация",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Изтриване",
+  "status.embed": "Embed",
   "status.favourite": "Предпочитани",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Споменаване",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Споделяне",
   "status.reblogged_by": "{name} сподели",
   "status.reply": "Отговор",
@@ -179,6 +188,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Съставяне",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Начало",
@@ -188,6 +198,15 @@
   "upload_button.label": "Добави медия",
   "upload_form.undo": "Отмяна",
   "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Expand video",
   "video_player.toggle_sound": "Звук",
   "video_player.toggle_visible": "Toggle visibility",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 95b3c60bf..286da3ac6 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -33,6 +33,7 @@
   "column.home": "Inici",
   "column.mutes": "Usuaris silenciats",
   "column.notifications": "Notificacions",
+  "column.pins": "Pinned toot",
   "column.public": "Línia de temps federada",
   "column_back_button.label": "Enrere",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "bloquejat",
   "compose_form.placeholder": "En què estàs pensant?",
-  "compose_form.privacy_disclaimer": "El teu missatge serà lliurat als usuaris esmentats en els dominis {domains}. Confies en {domainsCount, plural, one {that server} other {those servers}}? Els missatges privats només funcionen en instàncies Mastodon. Si {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, res indicarà que el teu missatge no es públic i pot ser impulsat (boosted) o ser visible per destinataris no desitjats.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Marcar multimèdia com a sensible",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "Estàs segur que vols silenciar {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": "Activitat",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Menjar i Beure",
   "emoji_button.label": "Inserir emoji",
   "emoji_button.nature": "Natura",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objectes",
   "emoji_button.people": "Gent",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Cercar...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Símbols",
   "emoji_button.travel": "Viatges i Llocs",
   "empty_column.community": "La línia de temps local és buida. Escriu alguna cosa públicament per fer rodar la pilota!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Informació addicional",
   "navigation_bar.logout": "Tancar sessió",
   "navigation_bar.mutes": "Usuaris silenciats",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Preferències",
   "navigation_bar.public_timeline": "Línia de temps federada",
   "notification.favourite": "{name} ha afavorit el teu estat",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
   "status.delete": "Esborrar",
+  "status.embed": "Embed",
   "status.favourite": "Favorit",
   "status.load_more": "Carrega més",
   "status.media_hidden": "Multimèdia amagat",
   "status.mention": "Esmentar @{name}",
   "status.mute_conversation": "Silenciar conversació",
   "status.open": "Ampliar aquest estat",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} ha retootejat",
   "status.reply": "Respondre",
@@ -179,6 +188,7 @@
   "status.show_less": "Mostra menys",
   "status.show_more": "Mostra més",
   "status.unmute_conversation": "Activar conversació",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compondre",
   "tabs_bar.federated_timeline": "Federada",
   "tabs_bar.home": "Inici",
@@ -188,6 +198,15 @@
   "upload_button.label": "Afegir multimèdia",
   "upload_form.undo": "Desfer",
   "upload_progress.label": "Pujant...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Ampliar el vídeo",
   "video_player.toggle_sound": "Alternar so",
   "video_player.toggle_visible": "Alternar visibilitat",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 67a99b765..461e7e304 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -1,92 +1,98 @@
 {
   "account.block": "@{name} blocken",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "Alles von {domain} verstecken",
+  "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.",
   "account.edit_profile": "Profil bearbeiten",
   "account.follow": "Folgen",
   "account.followers": "Folgende",
   "account.follows": "Folgt",
   "account.follows_you": "Folgt dir",
-  "account.media": "Media",
+  "account.media": "Medien",
   "account.mention": "@{name} erwähnen",
   "account.mute": "@{name} stummschalten",
   "account.posts": "Beiträge",
   "account.report": "@{name} melden",
-  "account.requested": "Warte auf Erlaubnis",
-  "account.share": "Share @{name}'s profile",
+  "account.requested": "Warte auf Erlaubnis. Klicke zum Abbrechen",
+  "account.share": "Profil von @{name} teilen",
   "account.unblock": "@{name} entblocken",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "{domain} wieder anzeigen",
   "account.unfollow": "Entfolgen",
   "account.unmute": "@{name} nicht mehr stummschalten",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "Komplettes Profil anzeigen",
   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "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": "Blockierte Benutzer",
+  "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
+  "bundle_column_error.retry": "Erneut versuchen",
+  "bundle_column_error.title": "Netzwerkfehlher",
+  "bundle_modal_error.close": "Schließen",
+  "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.",
+  "bundle_modal_error.retry": "Erneut versuchen",
+  "column.blocks": "Blockierte Profile",
   "column.community": "Lokale Zeitleiste",
   "column.favourites": "Favoriten",
   "column.follow_requests": "Folgeanfragen",
   "column.home": "Startseite",
-  "column.mutes": "Stummgeschaltete Benutzer",
+  "column.mutes": "Stummgeschaltete Profile",
   "column.notifications": "Mitteilungen",
+  "column.pins": "Pinned toot",
   "column.public": "Gesamtes bekanntes Netz",
   "column_back_button.label": "Zurück",
-  "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": "Einstellungen verbergen",
+  "column_header.moveLeft_settings": "Spalte links verschieben",
+  "column_header.moveRight_settings": "Spalte rechts verschieben",
+  "column_header.pin": "Anheften",
+  "column_header.show_settings": "Einstellungen anzeigen",
+  "column_header.unpin": "Lösen",
   "column_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "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": "Einstellungen",
+  "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.",
+  "compose_form.lock_disclaimer.lock": "gesperrt",
   "compose_form.placeholder": "Worüber möchtest du schreiben?",
-  "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Benutzer auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.",
   "compose_form.publish": "Tröt",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Medien als heikel markieren",
   "compose_form.spoiler": "Text hinter Warnung verbergen",
   "compose_form.spoiler_placeholder": "Inhaltswarnung",
-  "confirmation_modal.cancel": "Cancel",
-  "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
-  "confirmations.delete.confirm": "Delete",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
-  "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}?",
-  "emoji_button.activity": "Activity",
-  "emoji_button.flags": "Flags",
-  "emoji_button.food": "Food & Drink",
+  "confirmation_modal.cancel": "Abbrechen",
+  "confirmations.block.confirm": "Blockieren",
+  "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
+  "confirmations.delete.confirm": "Löschen",
+  "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?",
+  "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
+  "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.",
+  "confirmations.mute.confirm": "Stummschalten",
+  "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?",
+  "confirmations.unfollow.confirm": "Entfolgen",
+  "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?",
+  "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.",
+  "embed.preview": "So wird es aussehen:",
+  "emoji_button.activity": "Aktivitäten",
+  "emoji_button.custom": "Custom",
+  "emoji_button.flags": "Flaggen",
+  "emoji_button.food": "Essen und Trinken",
   "emoji_button.label": "Emoji einfügen",
-  "emoji_button.nature": "Nature",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.search": "Search...",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
+  "emoji_button.nature": "Natur",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Dinge",
+  "emoji_button.people": "Leute",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "Suche…",
+  "emoji_button.search_results": "Search results",
+  "emoji_button.symbols": "Symbole",
+  "emoji_button.travel": "Reise und Orte",
   "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!",
   "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.",
-  "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Benutzer anzutreffen.",
-  "empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
+  "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.",
+  "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.",
   "empty_column.home.public_timeline": "die öffentliche Zeitleiste",
   "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.",
-  "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Benutzern von anderen Instanzen, um es aufzufüllen.",
+  "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.",
   "follow_request.authorize": "Erlauben",
   "follow_request.reject": "Ablehnen",
-  "getting_started.appsshort": "Apps",
-  "getting_started.faq": "FAQ",
+  "getting_started.appsshort": "Anwendungen",
+  "getting_started.faq": "Häufig gestellte Fragen",
   "getting_started.heading": "Erste Schritte",
   "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
-  "getting_started.userguide": "User Guide",
+  "getting_started.userguide": "Bedienungsanleitung",
   "home.column_settings.advanced": "Fortgeschritten",
   "home.column_settings.basic": "Einfach",
   "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
@@ -94,27 +100,28 @@
   "home.column_settings.show_replies": "Antworten anzeigen",
   "home.settings": "Spalteneinstellungen",
   "lightbox.close": "Schließen",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "Weiter",
+  "lightbox.previous": "Zurück",
   "loading_indicator.label": "Lade…",
   "media_gallery.toggle_visible": "Sichtbarkeit einstellen",
   "missing_indicator.label": "Nicht gefunden",
-  "navigation_bar.blocks": "Blockierte Benutzer",
+  "navigation_bar.blocks": "Blockierte Profile",
   "navigation_bar.community_timeline": "Lokale Zeitleiste",
   "navigation_bar.edit_profile": "Profil bearbeiten",
   "navigation_bar.favourites": "Favoriten",
   "navigation_bar.follow_requests": "Folgeanfragen",
   "navigation_bar.info": "Erweiterte Informationen",
   "navigation_bar.logout": "Abmelden",
-  "navigation_bar.mutes": "Stummgeschaltete Benutzer",
+  "navigation_bar.mutes": "Stummgeschaltete Profile",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Einstellungen",
   "navigation_bar.public_timeline": "Föderierte Zeitleiste",
   "notification.favourite": "{name} favorisierte deinen Status",
   "notification.follow": "{name} folgt dir",
   "notification.mention": "{name} erwähnte dich",
   "notification.reblog": "{name} teilte deinen Status",
-  "notifications.clear": "Mitteilungen beseitigen",
-  "notifications.clear_confirmation": "Bist du sicher, dass du alle Mitteilungen beseitigen willst?",
+  "notifications.clear": "Mitteilungen löschen",
+  "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?",
   "notifications.column_settings.alert": "Desktop-Benachrichtigungen",
   "notifications.column_settings.favourite": "Favorisierungen:",
   "notifications.column_settings.follow": "Neue Folgende:",
@@ -124,28 +131,28 @@
   "notifications.column_settings.reblog": "Geteilte Beiträge:",
   "notifications.column_settings.show": "In der Spalte anzeigen",
   "notifications.column_settings.sound": "Ton abspielen",
-  "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.handle": "You are on {domain}, so your full handle is {handle}",
-  "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",
+  "onboarding.done": "Fertig",
+  "onboarding.next": "Weiter",
+  "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.",
+  "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.",
+  "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.",
+  "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.",
+  "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}",
+  "onboarding.page_one.welcome": "Willkommen bei Mastodon!",
+  "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.",
+  "onboarding.page_six.almost_done": "Fast fertig…",
+  "onboarding.page_six.appetoot": "Guten Appetröt!",
+  "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.",
+  "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+  "onboarding.page_six.guidelines": "Richtlinien",
+  "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!",
+  "onboarding.page_six.various_app": "mobile Anwendungen",
+  "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.",
+  "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.",
+  "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.",
+  "onboarding.skip": "Überspringen",
   "privacy.change": "Privatsphäre des Status anpassen",
-  "privacy.direct.long": "Beitrag nur an erwähnte Benutzer",
+  "privacy.direct.long": "Beitrag nur an erwähnte Profile",
   "privacy.direct.short": "Direkt",
   "privacy.private.long": "Beitrag nur an Folgende",
   "privacy.private.short": "Privat",
@@ -159,15 +166,17 @@
   "report.target": "Melden",
   "search.placeholder": "Suche",
   "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
-  "standalone.public_title": "A look inside...",
-  "status.cannot_reblog": "This post cannot be boosted",
+  "standalone.public_title": "Vorschau…",
+  "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
   "status.delete": "Löschen",
+  "status.embed": "Einbetten",
   "status.favourite": "Favorisieren",
   "status.load_more": "Weitere laden",
   "status.media_hidden": "Medien versteckt",
   "status.mention": "Erwähnen",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "Thread stummschalten",
   "status.open": "Öffnen",
+  "status.pin": "Auf dem Profil anheften",
   "status.reblog": "Teilen",
   "status.reblogged_by": "{name} teilte",
   "status.reply": "Antworten",
@@ -175,19 +184,29 @@
   "status.report": "@{name} melden",
   "status.sensitive_toggle": "Klicke, um sie zu sehen",
   "status.sensitive_warning": "Heikle Inhalte",
-  "status.share": "Share",
+  "status.share": "Teilen",
   "status.show_less": "Weniger anzeigen",
   "status.show_more": "Mehr anzeigen",
-  "status.unmute_conversation": "Unmute conversation",
+  "status.unmute_conversation": "Stummschaltung von Thread aufheben",
+  "status.unpin": "Vom Profil lösen",
   "tabs_bar.compose": "Schreiben",
   "tabs_bar.federated_timeline": "Föderation",
-  "tabs_bar.home": "Home",
+  "tabs_bar.home": "Startseite",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Mitteilungen",
   "upload_area.title": "Hereinziehen zum Hochladen",
   "upload_button.label": "Mediendatei hinzufügen",
   "upload_form.undo": "Entfernen",
   "upload_progress.label": "Lade hoch…",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Videoanzeige vergrößern",
   "video_player.toggle_sound": "Ton umschalten",
   "video_player.toggle_visible": "Sichtbarkeit umschalten",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index e5d541cd6..5b711fd26 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -189,6 +189,18 @@
       {
         "defaultMessage": "Unmute conversation",
         "id": "status.unmute_conversation"
+      },
+      {
+        "defaultMessage": "Pin on profile",
+        "id": "status.pin"
+      },
+      {
+        "defaultMessage": "Unpin from profile",
+        "id": "status.unpin"
+      },
+      {
+        "defaultMessage": "Embed",
+        "id": "status.embed"
       }
     ],
     "path": "app/javascript/mastodon/components/status_action_bar.json"
@@ -424,7 +436,7 @@
         "id": "account.follow"
       },
       {
-        "defaultMessage": "Awaiting approval",
+        "defaultMessage": "Awaiting approval. Click to cancel follow request",
         "id": "account.requested"
       },
       {
@@ -505,6 +517,22 @@
         "id": "emoji_button.search"
       },
       {
+        "defaultMessage": "No emojos!! (╯°□°)╯︵ ┻━┻",
+        "id": "emoji_button.not_found"
+      },
+      {
+        "defaultMessage": "Custom",
+        "id": "emoji_button.custom"
+      },
+      {
+        "defaultMessage": "Frequently used",
+        "id": "emoji_button.recent"
+      },
+      {
+        "defaultMessage": "Search results",
+        "id": "emoji_button.search_results"
+      },
+      {
         "defaultMessage": "People",
         "id": "emoji_button.people"
       },
@@ -670,10 +698,6 @@
       {
         "defaultMessage": "locked",
         "id": "compose_form.lock_disclaimer.lock"
-      },
-      {
-        "defaultMessage": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
-        "id": "compose_form.privacy_disclaimer"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
@@ -801,6 +825,10 @@
         "id": "navigation_bar.info"
       },
       {
+        "defaultMessage": "Pinned toots",
+        "id": "navigation_bar.pins"
+      },
+      {
         "defaultMessage": "FAQ",
         "id": "getting_started.faq"
       },
@@ -983,6 +1011,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Pinned toot",
+        "id": "column.pins"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/pinned_statuses/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Federated timeline",
         "id": "column.public"
       },
@@ -1035,6 +1072,18 @@
       {
         "defaultMessage": "Share",
         "id": "status.share"
+      },
+      {
+        "defaultMessage": "Pin on profile",
+        "id": "status.pin"
+      },
+      {
+        "defaultMessage": "Unpin from profile",
+        "id": "status.unpin"
+      },
+      {
+        "defaultMessage": "Embed",
+        "id": "status.embed"
       }
     ],
     "path": "app/javascript/mastodon/features/status/components/action_bar.json"
@@ -1111,6 +1160,23 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Embed",
+        "id": "status.embed"
+      },
+      {
+        "defaultMessage": "Embed this status on your website by copying the code below.",
+        "id": "embed.instructions"
+      },
+      {
+        "defaultMessage": "Here is what it will look like:",
+        "id": "embed.preview"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/embed_modal.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Close",
         "id": "lightbox.close"
       },
@@ -1280,10 +1346,50 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Close",
-        "id": "lightbox.close"
+        "defaultMessage": "Play",
+        "id": "video.play"
+      },
+      {
+        "defaultMessage": "Pause",
+        "id": "video.pause"
+      },
+      {
+        "defaultMessage": "Mute sound",
+        "id": "video.mute"
+      },
+      {
+        "defaultMessage": "Unmute sound",
+        "id": "video.unmute"
+      },
+      {
+        "defaultMessage": "Hide video",
+        "id": "video.hide"
+      },
+      {
+        "defaultMessage": "Expand video",
+        "id": "video.expand"
+      },
+      {
+        "defaultMessage": "Close video",
+        "id": "video.close"
+      },
+      {
+        "defaultMessage": "Full screen",
+        "id": "video.fullscreen"
+      },
+      {
+        "defaultMessage": "Exit full screen",
+        "id": "video.exit_fullscreen"
+      },
+      {
+        "defaultMessage": "Sensitive content",
+        "id": "status.sensitive_warning"
+      },
+      {
+        "defaultMessage": "Click to view",
+        "id": "status.sensitive_toggle"
       }
     ],
-    "path": "app/javascript/mastodon/features/ui/components/video_modal.json"
+    "path": "app/javascript/mastodon/features/video/index.json"
   }
 ]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 2ea2062d3..fc6aa4280 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -12,7 +12,7 @@
   "account.mute": "Mute @{name}",
   "account.posts": "Posts",
   "account.report": "Report @{name}",
-  "account.requested": "Awaiting approval",
+  "account.requested": "Awaiting approval. Click to cancel follow request",
   "account.share": "Share @{name}'s profile",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unhide {domain}",
@@ -33,6 +33,7 @@
   "column.home": "Home",
   "column.mutes": "Muted users",
   "column.notifications": "Notifications",
+  "column.pins": "Pinned toots",
   "column.public": "Federated timeline",
   "column_back_button.label": "Back",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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?",
-  "compose_form.privacy_disclaimer": "Your post will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is not a public post, and it may be boosted or otherwise made visible to unintended recipients.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Mark media as sensitive",
@@ -63,14 +63,20 @@
   "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!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "About this instance",
   "navigation_bar.logout": "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",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
+  "status.embed": "Embed",
   "status.favourite": "Favourite",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boosted",
   "status.reply": "Reply",
@@ -179,6 +188,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
@@ -188,6 +198,15 @@
   "upload_button.label": "Add media",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Expand video",
   "video_player.toggle_sound": "Toggle sound",
   "video_player.toggle_visible": "Toggle visibility",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 960d747ec..21b92ed3a 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -33,6 +33,7 @@
   "column.home": "Hejmo",
   "column.mutes": "Muted users",
   "column.notifications": "Sciigoj",
+  "column.pins": "Pinned toot",
   "column.public": "Fratara tempolinio",
   "column_back_button.label": "Reveni",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "Pri kio vi pensas?",
-  "compose_form.privacy_disclaimer": "Via privata mesaĝo estos sendita nur al menciitaj uzantoj en {domains}. Ĉu vi fidas {domainsCount, plural, one {tiun servilon} other {tiujn servilojn}}? Mesaĝa privateco funkcias nur en aperaĵoj de Mastodon. Se {domains} {domainsCount, plural, one {ne estas aperaĵo de Mastodon} other {ne estas aperaĵoj de Mastodon}}, estos neniu indiko ke via mesaĝo estas privata, kaj ĝi povus esti diskonigita aŭ videbligita al necelitaj ricevantoj.",
   "compose_form.publish": "Hup",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Marki ke la enhavo estas tikla",
@@ -63,14 +63,20 @@
   "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!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Extended information",
   "navigation_bar.logout": "Elsaluti",
   "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Preferoj",
   "navigation_bar.public_timeline": "Fratara tempolinio",
   "notification.favourite": "{name} favoris vian mesaĝon",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Forigi",
+  "status.embed": "Embed",
   "status.favourite": "Favori",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mencii @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Diskonigi",
   "status.reblogged_by": "{name} diskonigita",
   "status.reply": "Respondi",
@@ -179,6 +188,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Ekskribi",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Hejmo",
@@ -188,6 +198,15 @@
   "upload_button.label": "Aldoni enhavaĵon",
   "upload_form.undo": "Malfari",
   "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Expand video",
   "video_player.toggle_sound": "Aktivigi sonojn",
   "video_player.toggle_visible": "Toggle visibility",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 212d16639..59c7dc5a7 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -1,104 +1,110 @@
 {
   "account.block": "Bloquear",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "Ocultar todo de {domain}",
+  "account.disclaimer_full": "La siguiente información del usuario puede estar incompleta.",
   "account.edit_profile": "Editar perfil",
   "account.follow": "Seguir",
   "account.followers": "Seguidores",
-  "account.follows": "Seguir",
+  "account.follows": "Sigue",
   "account.follows_you": "Te sigue",
   "account.media": "Media",
-  "account.mention": "Mencionar",
-  "account.mute": "Silenciar",
+  "account.mention": "Mencionar a @{name}",
+  "account.mute": "Silenciar a @{name}",
   "account.posts": "Publicaciones",
-  "account.report": "Report @{name}",
+  "account.report": "Reportar a @{name}",
   "account.requested": "Esperando aprobación",
-  "account.share": "Share @{name}'s profile",
-  "account.unblock": "Desbloquear",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.share": "Compartir el perfil de @{name}",
+  "account.unblock": "Desbloquear a @{name}",
+  "account.unblock_domain": "Mostrar a {domain}",
   "account.unfollow": "Dejar de seguir",
-  "account.unmute": "Unmute @{name}",
-  "account.view_full_profile": "View full profile",
-  "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",
-  "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",
+  "account.unmute": "Dejar de silenciar a @{name}",
+  "account.view_full_profile": "Ver perfil completo",
+  "boost_modal.combo": "Puedes presionar {combo} para saltear este aviso la próxima vez",
+  "bundle_column_error.body": "Algo salió mal al cargar este componente.",
+  "bundle_column_error.retry": "Inténtalo de nuevo",
+  "bundle_column_error.title": "Error de red",
+  "bundle_modal_error.close": "Cerrar",
+  "bundle_modal_error.message": "Algo salió mal al cargar este componente.",
+  "bundle_modal_error.retry": "Inténtalo de nuevo",
   "column.blocks": "Usuarios bloqueados",
-  "column.community": "Historia local",
+  "column.community": "Línea de tiempo local",
   "column.favourites": "Favoritos",
-  "column.follow_requests": "Solicitudes para seguirte",
+  "column.follow_requests": "Solicitudes de seguimiento",
   "column.home": "Inicio",
   "column.mutes": "Usuarios silenciados",
   "column.notifications": "Notificaciones",
+  "column.pins": "Toot fijado",
   "column.public": "Historia federada",
   "column_back_button.label": "Atrás",
-  "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_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "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_header.hide_settings": "Ocultar ajustes",
+  "column_header.moveLeft_settings": "Mover columna a la izquierda",
+  "column_header.moveRight_settings": "Mover columna a la derecha",
+  "column_header.pin": "Fijar",
+  "column_header.show_settings": "Mostrar ajustes",
+  "column_header.unpin": "Dejar de fijar",
+  "column_subheading.navigation": "Navegación",
+  "column_subheading.settings": "Ajustes",
+  "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",
   "compose_form.placeholder": "¿En qué estás pensando?",
-  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
   "compose_form.publish": "Tootear",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Marcar contenido como sensible",
-  "compose_form.spoiler": "Ocultar texto tras advertencia",
+  "compose_form.spoiler": "Ocultar texto tras una advertencia",
   "compose_form.spoiler_placeholder": "Advertencia de contenido",
-  "confirmation_modal.cancel": "Cancel",
-  "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
-  "confirmations.delete.confirm": "Delete",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
-  "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}?",
-  "emoji_button.activity": "Activity",
-  "emoji_button.flags": "Flags",
-  "emoji_button.food": "Food & Drink",
+  "confirmation_modal.cancel": "Cancelar",
+  "confirmations.block.confirm": "Bloquear",
+  "confirmations.block.message": "¿Estás seguro de que quieres bloquear a {name}?",
+  "confirmations.delete.confirm": "Eliminar",
+  "confirmations.delete.message": "¿Estás seguro de que quieres borrar este toot?",
+  "confirmations.domain_block.confirm": "Ocultar dominio entero",
+  "confirmations.domain_block.message": "¿Seguro de que quieres bloquear al dominio entero? En algunos casos es preferible bloquear o silenciar objetivos determinados.",
+  "confirmations.mute.confirm": "Silenciar",
+  "confirmations.mute.message": "¿Estás seguro de que quieres silenciar a {name}?",
+  "confirmations.unfollow.confirm": "Dejar de seguir",
+  "confirmations.unfollow.message": "¿Estás seguro de que quieres dejar de seguir a {name}?",
+  "embed.instructions": "Añade este toot a tu sitio web con el siguiente código.",
+  "embed.preview": "Así es como se verá:",
+  "emoji_button.activity": "Actividad",
+  "emoji_button.custom": "Custom",
+  "emoji_button.flags": "Marcas",
+  "emoji_button.food": "Comida y bebida",
   "emoji_button.label": "Insertar emoji",
-  "emoji_button.nature": "Nature",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.search": "Search...",
-  "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.hashtag": "There is nothing in this hashtag yet.",
-  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
-  "empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
-  "empty_column.home.public_timeline": "the public timeline",
-  "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",
+  "emoji_button.nature": "Naturaleza",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Objetos",
+  "emoji_button.people": "Gente",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "Buscar…",
+  "emoji_button.search_results": "Search results",
+  "emoji_button.symbols": "Símbolos",
+  "emoji_button.travel": "Viajes y lugares",
+  "empty_column.community": "La línea de tiempo local está vacía. ¡Escribe algo para empezar la fiesta!",
+  "empty_column.hashtag": "No hay nada en este hashtag aún.",
+  "empty_column.home": "No estás siguiendo a nadie aún. Visita {public} o haz búsquedas para empezar y conocer gente nueva.",
+  "empty_column.home.inactivity": "Tus notificaciones están vacías. Si has estado inactivo por un tiempo, se regenerará para ti pronto.",
+  "empty_column.home.public_timeline": "la línea de tiempo pública",
+  "empty_column.notifications": "No tienes ninguna notificación aún. Interactúa con otros para empezar una conversación.",
+  "empty_column.public": "¡No hay nada aquí! Escribe algo públicamente, o sigue usuarios de otras instancias manualmente para llenarlo.",
+  "follow_request.authorize": "Autorizar",
+  "follow_request.reject": "Rechazar",
+  "getting_started.appsshort": "Aplicaciones",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Primeros pasos",
   "getting_started.open_source_notice": "Mastodon es software libre. Puedes contribuir o reportar errores en {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",
+  "getting_started.userguide": "Guía de usuario",
+  "home.column_settings.advanced": "Avanzado",
+  "home.column_settings.basic": "Básico",
+  "home.column_settings.filter_regex": "Filtrar con expresiones regulares",
+  "home.column_settings.show_reblogs": "Mostrar retoots",
+  "home.column_settings.show_replies": "Mostrar respuestas",
+  "home.settings": "Ajustes de columna",
   "lightbox.close": "Cerrar",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
-  "loading_indicator.label": "Cargando...",
-  "media_gallery.toggle_visible": "Toggle visibility",
-  "missing_indicator.label": "Not found",
+  "lightbox.next": "Siguiente",
+  "lightbox.previous": "Anterior",
+  "loading_indicator.label": "Cargando…",
+  "media_gallery.toggle_visible": "Cambiar visibilidad",
+  "missing_indicator.label": "No encontrado",
   "navigation_bar.blocks": "Usuarios bloqueados",
   "navigation_bar.community_timeline": "Historia local",
   "navigation_bar.edit_profile": "Editar perfil",
@@ -107,43 +113,44 @@
   "navigation_bar.info": "Información adicional",
   "navigation_bar.logout": "Cerrar sesión",
   "navigation_bar.mutes": "Usuarios silenciados",
+  "navigation_bar.pins": "Toots fijados",
   "navigation_bar.preferences": "Preferencias",
   "navigation_bar.public_timeline": "Historia federada",
   "notification.favourite": "{name} marcó tu estado como favorito",
   "notification.follow": "{name} te empezó a seguir",
   "notification.mention": "{name} te ha mencionado",
   "notification.reblog": "{name} ha retooteado tu estado",
-  "notifications.clear": "Clear notifications",
-  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.clear": "Limpiar notificaciones",
+  "notifications.clear_confirmation": "¿Seguro que quieres limpiar permanentemente todas tus notificaciones?",
   "notifications.column_settings.alert": "Notificaciones de escritorio",
   "notifications.column_settings.favourite": "Favoritos:",
   "notifications.column_settings.follow": "Nuevos seguidores:",
   "notifications.column_settings.mention": "Menciones:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "Notificaciones push:",
+  "notifications.column_settings.push_meta": "Este dispositivo:",
   "notifications.column_settings.reblog": "Retoots:",
   "notifications.column_settings.show": "Mostrar en columna",
-  "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.",
-  "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.handle": "You are on {domain}, so your full handle is {handle}",
-  "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.column_settings.sound": "Reproducir sonido",
+  "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.",
+  "onboarding.page_four.home": "La línea de tiempo principal muestra toots de gente que sigues.",
+  "onboarding.page_four.notifications": "Las notificaciones se muestran cuando alguien interactúa contigo.",
+  "onboarding.page_one.federation": "Mastodon es una red de servidores federados que conforman una red social aún más grande. Llamamos a estos servidores instancias.",
+  "onboarding.page_one.handle": "Estás en {domain}, así que tu nombre de usuario completo es {handle}",
+  "onboarding.page_one.welcome": "¡Bienvenido a Mastodon!",
+  "onboarding.page_six.admin": "El administrador de tu instancia es {admin}.",
+  "onboarding.page_six.almost_done": "Ya casi…",
+  "onboarding.page_six.appetoot": "¡Bon Appetoot!",
+  "onboarding.page_six.apps_available": "Hay {apps} disponibles para iOS, Android y otras plataformas.",
+  "onboarding.page_six.github": "Mastodon es software libre. Puedes reportar errores, pedir funciones nuevas, o contribuir al código en {github}.",
+  "onboarding.page_six.guidelines": "guías de la comunidad",
+  "onboarding.page_six.read_guidelines": "¡Por favor lee las {guidelines} de {domain}!",
+  "onboarding.page_six.various_app": "aplicaciones móviles",
+  "onboarding.page_three.profile": "Edita tu perfil para cambiar tu avatar, biografía y nombre de cabecera. Ahí, también encontrarás otros ajustes.",
+  "onboarding.page_three.search": "Usa la barra de búsqueda y revisa hashtags, como {illustration} y {introductions}. Para ver a alguien que no es de tu propia instancia, usa su nombre de usuario completo.",
+  "onboarding.page_two.compose": "Escribe toots en la columna de redacción. Puedes subir imágenes, cambiar ajustes de privacidad, y añadir advertencias de contenido con los siguientes íconos.",
+  "onboarding.skip": "Saltar",
   "privacy.change": "Ajustar privacidad",
   "privacy.direct.long": "Sólo mostrar a los usuarios mencionados",
   "privacy.direct.short": "Directo",
@@ -154,42 +161,54 @@
   "privacy.unlisted.long": "No mostrar en la historia federada",
   "privacy.unlisted.short": "Sin federar",
   "reply_indicator.cancel": "Cancelar",
-  "report.placeholder": "Additional comments",
-  "report.submit": "Submit",
-  "report.target": "Reporting",
+  "report.placeholder": "Comentarios adicionales",
+  "report.submit": "Publicar",
+  "report.target": "Reportando",
   "search.placeholder": "Buscar",
-  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "standalone.public_title": "A look inside...",
-  "status.cannot_reblog": "This post cannot be boosted",
+  "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
+  "standalone.public_title": "Un pequeño vistazo...",
+  "status.cannot_reblog": "Este toot no puede retootearse",
   "status.delete": "Borrar",
+  "status.embed": "Incrustado",
   "status.favourite": "Favorito",
-  "status.load_more": "Load more",
-  "status.media_hidden": "Media hidden",
+  "status.load_more": "Cargar más",
+  "status.media_hidden": "Contenido multimedia oculto",
   "status.mention": "Mencionar",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "Silenciar conversación",
   "status.open": "Expandir estado",
-  "status.reblog": "Retoot",
+  "status.pin": "Fijar",
+  "status.reblog": "Retootear",
   "status.reblogged_by": "Retooteado por {name}",
   "status.reply": "Responder",
-  "status.replyAll": "Reply to thread",
+  "status.replyAll": "Responder al hilo",
   "status.report": "Reportar",
-  "status.sensitive_toggle": "Click para ver",
+  "status.sensitive_toggle": "Haz clic para ver",
   "status.sensitive_warning": "Contenido sensible",
-  "status.share": "Share",
+  "status.share": "Compartir",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar más",
-  "status.unmute_conversation": "Unmute conversation",
+  "status.unmute_conversation": "Dejar de silenciar conversación",
+  "status.unpin": "Dejar de fijar",
   "tabs_bar.compose": "Redactar",
-  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.federated_timeline": "Federado",
   "tabs_bar.home": "Inicio",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificaciones",
-  "upload_area.title": "Drag & drop to upload",
+  "upload_area.title": "Arrastra y suelta para subir",
   "upload_button.label": "Subir multimedia",
   "upload_form.undo": "Deshacer",
-  "upload_progress.label": "Uploading...",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Act/Desac. sonido",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "upload_progress.label": "Subiendo…",
+  "video.close": "Cerrar video",
+  "video.exit_fullscreen": "Salir de pantalla completa",
+  "video.expand": "Expandir vídeo",
+  "video.fullscreen": "Pantalla completa",
+  "video.hide": "Ocultar vídeo",
+  "video.mute": "Silenciar sonido",
+  "video.pause": "Pausar",
+  "video.play": "Reproducir",
+  "video.unmute": "Dejar de silenciar sonido",
+  "video_player.expand": "Expandir vídeo",
+  "video_player.toggle_sound": "Activar/Desactivar sonido",
+  "video_player.toggle_visible": "Cambiar visibilidad",
+  "video_player.video_error": "No se pudo reproducir el vídeo"
 }
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index d2682ef12..6e4771392 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -1,7 +1,7 @@
 {
   "account.block": "مسدودسازی @{name}",
   "account.block_domain": "پنهان‌سازی همه چیز از سرور {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.disclaimer_full": "اطلاعات زیر ممکن است نمایهٔ این کاربر را به تمامی نشان ندهد.",
   "account.edit_profile": "ویرایش نمایه",
   "account.follow": "پی بگیرید",
   "account.followers": "پیگیران",
@@ -13,7 +13,7 @@
   "account.posts": "نوشته‌ها",
   "account.report": "گزارش @{name}",
   "account.requested": "در انتظار پذیرش",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "هم‌رسانی نمایهٔ @{name}",
   "account.unblock": "رفع انسداد @{name}",
   "account.unblock_domain": "رفع پنهان‌سازی از {domain}",
   "account.unfollow": "پایان پیگیری",
@@ -33,6 +33,7 @@
   "column.home": "خانه",
   "column.mutes": "کاربران بی‌صداشده",
   "column.notifications": "اعلان‌ها",
+  "column.pins": "نوشته‌های ثابت",
   "column.public": "نوشته‌های همه‌جا",
   "column_back_button.label": "بازگشت",
   "column_header.hide_settings": "نهفتن تنظیمات",
@@ -46,7 +47,6 @@
   "compose_form.lock_disclaimer": "حساب شما {locked} نیست. هر کسی می‌تواند پیگیر شما شود و نوشته‌های ویژهٔ پیگیران شما را ببیند.",
   "compose_form.lock_disclaimer.lock": "قفل",
   "compose_form.placeholder": "تازه چه خبر؟",
-  "compose_form.privacy_disclaimer": "نوشتهٔ خصوصی شما به کاربران نام‌برده‌شده در {domains} فرستاده می‌شود. آیا به {domainsCount, plural, one {آن سرور} other {آن سرورها}} اعتماد دارید؟ تنظیمات حریم خصوصی نوشته‌ها تنها در سرورهای ماستدون کار می‌کند. اگر {domains} {domainsCount, plural, one {یک سرور ماستدون نباشد} other {سرورهای ماستدون نباشند}}، اشاره‌ای به خصوصی‌بودن نوشتهٔ شما نخواهد شد و شاید نوشتهٔ شما هم‌رسان شود یا برای کاربرانی که نمی‌خواهید نمایش یابد.",
   "compose_form.publish": "بوق",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "تصاویر حساس هستند",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "آیا واقعاً می‌خواهید {name} را بی‌صدا کنید؟",
   "confirmations.unfollow.confirm": "لغو پیگیری",
   "confirmations.unfollow.message": "آیا واقعاً می‌خواهید به پیگیری از {name} پایان دهید؟",
+  "embed.instructions": "برای جاگذاری این نوشته در سایت خودتان، کد زیر را کپی کنید.",
+  "embed.preview": "نوشتهٔ جاگذاری‌شده این گونه به نظر خواهد رسید:",
   "emoji_button.activity": "فعالیت",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "پرچم‌ها",
   "emoji_button.food": "غذا و نوشیدنی",
   "emoji_button.label": "افزودن شکلک",
   "emoji_button.nature": "طبیعت",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "اشیا",
   "emoji_button.people": "مردم",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "جستجو...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "نمادها",
   "emoji_button.travel": "سفر و مکان",
   "empty_column.community": "فهرست نوشته‌های محلی خالی است. چیزی بنویسید تا چرخش بچرخد!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "اطلاعات تکمیلی",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "کاربران بی‌صداشده",
+  "navigation_bar.pins": "نوشته‌های ثابت",
   "navigation_bar.preferences": "ترجیحات",
   "navigation_bar.public_timeline": "نوشته‌های همه‌جا",
   "notification.favourite": "‫{name}‬ نوشتهٔ شما را پسندید",
@@ -162,12 +169,14 @@
   "standalone.public_title": "نگاهی به کاربران این سرور...",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
   "status.delete": "پاک‌کردن",
+  "status.embed": "جاگذاری",
   "status.favourite": "پسندیدن",
   "status.load_more": "بیشتر نشان بده",
   "status.media_hidden": "تصویر پنهان شده",
   "status.mention": "نام‌بردن از @{name}",
   "status.mute_conversation": "بی‌صداکردن گفتگو",
   "status.open": "این نوشته را باز کن",
+  "status.pin": "نوشتهٔ ثابت نمایه",
   "status.reblog": "بازبوقیدن",
   "status.reblogged_by": "‫{name}‬ بازبوقید",
   "status.reply": "پاسخ",
@@ -179,6 +188,7 @@
   "status.show_less": "نهفتن",
   "status.show_more": "نمایش",
   "status.unmute_conversation": "باصداکردن گفتگو",
+  "status.unpin": "برداشتن نوشتهٔ ثابت نمایه",
   "tabs_bar.compose": "بنویسید",
   "tabs_bar.federated_timeline": "همگانی",
   "tabs_bar.home": "خانه",
@@ -188,6 +198,15 @@
   "upload_button.label": "افزودن تصویر",
   "upload_form.undo": "واگردانی",
   "upload_progress.label": "بارگذاری...",
+  "video.close": "بستن ویدیو",
+  "video.exit_fullscreen": "خروج از حالت تمام صفحه",
+  "video.expand": "بزرگ‌کردن ویدیو",
+  "video.fullscreen": "تمام صفحه",
+  "video.hide": "نهفتن ویدیو",
+  "video.mute": "قطع صدا",
+  "video.pause": "توقف",
+  "video.play": "پخش",
+  "video.unmute": "پخش صدا",
   "video_player.expand": "بازکردن ویدیو",
   "video_player.toggle_sound": "تغییر صداداری",
   "video_player.toggle_visible": "تغییر پیدایی",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index cb9e9c2a6..ccdf19dd6 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -33,6 +33,7 @@
   "column.home": "Koti",
   "column.mutes": "Muted users",
   "column.notifications": "Ilmoitukset",
+  "column.pins": "Pinned toot",
   "column.public": "Yleinen aikajana",
   "column_back_button.label": "Takaisin",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "Mitä sinulla on mielessä?",
-  "compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Merkitse media herkäksi",
@@ -63,14 +63,20 @@
   "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!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Extended information",
   "navigation_bar.logout": "Kirjaudu ulos",
   "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Ominaisuudet",
   "navigation_bar.public_timeline": "Yleinen aikajana",
   "notification.favourite": "{name} tykkäsi statuksestasi",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Poista",
+  "status.embed": "Embed",
   "status.favourite": "Tykkää",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mainitse @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Buustaa",
   "status.reblogged_by": "{name} buustasi",
   "status.reply": "Vastaa",
@@ -179,6 +188,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Luo",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Koti",
@@ -188,6 +198,15 @@
   "upload_button.label": "Lisää mediaa",
   "upload_form.undo": "Peru",
   "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Expand video",
   "video_player.toggle_sound": "Äänet päälle/pois",
   "video_player.toggle_visible": "Toggle visibility",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index ad9060d25..417c1062a 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -13,18 +13,18 @@
   "account.posts": "Statuts",
   "account.report": "Signaler",
   "account.requested": "Invitation envoyée",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "Partager le profil de @{name}",
   "account.unblock": "Débloquer",
   "account.unblock_domain": "Ne plus masquer {domain}",
   "account.unfollow": "Ne plus suivre",
   "account.unmute": "Ne plus masquer",
   "account.view_full_profile": "Afficher le profil complet",
   "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.body": "Une erreur s’est produite lors du chargement de ce composant.",
   "bundle_column_error.retry": "Réessayer",
   "bundle_column_error.title": "Erreur réseau",
   "bundle_modal_error.close": "Fermer",
-  "bundle_modal_error.message": "Une erreur s'est produite lors du chargement de ce composant.",
+  "bundle_modal_error.message": "Une erreur s’est produite lors du chargement de ce composant.",
   "bundle_modal_error.retry": "Réessayer",
   "column.blocks": "Comptes bloqués",
   "column.community": "Fil public local",
@@ -33,22 +33,22 @@
   "column.home": "Accueil",
   "column.mutes": "Comptes masqués",
   "column.notifications": "Notifications",
+  "column.pins": "Pouets épinglés",
   "column.public": "Fil public global",
   "column_back_button.label": "Retour",
-  "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.hide_settings": "Masquer les paramètres",
+  "column_header.moveLeft_settings": "Déplacer la colonne vers la gauche",
+  "column_header.moveRight_settings": "Déplacer la colonne vers la droite",
   "column_header.pin": "Épingler",
-  "column_header.show_settings": "Show settings",
+  "column_header.show_settings": "Afficher les paramètres",
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
-  "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets restreints.",
+  "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é",
   "compose_form.placeholder": "Qu’avez-vous en tête ?",
-  "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. Si {domains} {domainsCount, plural, one {n’est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n’y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d’une autre manière à d’autres personnes imprévues.",
   "compose_form.publish": "Pouet ",
-  "compose_form.publish_loud": "{publish}!",
+  "compose_form.publish_loud": "{publish} !",
   "compose_form.sensitive": "Marquer le média comme sensible",
   "compose_form.spoiler": "Masquer le texte derrière un avertissement",
   "compose_form.spoiler_placeholder": "Écrivez ici votre avertissement",
@@ -62,15 +62,21 @@
   "confirmations.mute.confirm": "Masquer",
   "confirmations.mute.message": "Confirmez vous le masquage de {name} ?",
   "confirmations.unfollow.confirm": "Ne plus suivre",
-  "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?",
+  "confirmations.unfollow.message": "Voulez-vous arrêter de suivre {name} ?",
+  "embed.instructions": "Intégrez ce statut à votre site en copiant ce code ci-dessous.",
+  "embed.preview": "Il apparaîtra comme cela : ",
   "emoji_button.activity": "Activités",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Drapeaux",
   "emoji_button.food": "Boire et manger",
   "emoji_button.label": "Insérer un emoji",
   "emoji_button.nature": "Nature",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objets",
   "emoji_button.people": "Personnages",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Recherche…",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Symboles",
   "emoji_button.travel": "Lieux et voyages",
   "empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !",
@@ -94,8 +100,8 @@
   "home.column_settings.show_replies": "Afficher les réponses",
   "home.settings": "Paramètres de la colonne",
   "lightbox.close": "Fermer",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "Suivant",
+  "lightbox.previous": "Précédent",
   "loading_indicator.label": "Chargement…",
   "media_gallery.toggle_visible": "Modifier la visibilité",
   "missing_indicator.label": "Non trouvé",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Plus d’informations",
   "navigation_bar.logout": "Déconnexion",
   "navigation_bar.mutes": "Comptes masqués",
+  "navigation_bar.pins": "Pouets épinglés",
   "navigation_bar.preferences": "Préférences",
   "navigation_bar.public_timeline": "Fil public global",
   "notification.favourite": "{name} a ajouté à ses favoris :",
@@ -134,8 +141,8 @@
   "onboarding.page_one.welcome": "Bienvenue sur Mastodon !",
   "onboarding.page_six.admin": "L’administrateur⋅trice de votre instance est {admin}",
   "onboarding.page_six.almost_done": "Nous y sommes presque…",
-  "onboarding.page_six.appetoot": "Bon Appétoot!",
-  "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon Appétoot!",
+  "onboarding.page_six.appetoot": "Bon appouétit !",
+  "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon appouétit !",
   "onboarding.page_six.github": "Mastodon est un logiciel libre, gratuit et open-source. Vous pouvez rapporter des bogues, suggérer des fonctionnalités, ou contribuer à son développement sur {github}.",
   "onboarding.page_six.guidelines": "règles de la communauté",
   "onboarding.page_six.read_guidelines": "S’il vous plaît, n’oubliez pas de lire les {guidelines} !",
@@ -159,15 +166,17 @@
   "report.target": "Signalement",
   "search.placeholder": "Rechercher",
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
-  "standalone.public_title": "Coup d'œil",
+  "standalone.public_title": "Jeter un coup d’œil…",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
   "status.delete": "Effacer",
+  "status.embed": "Intégrer",
   "status.favourite": "Ajouter aux favoris",
   "status.load_more": "Charger plus",
   "status.media_hidden": "Média caché",
   "status.mention": "Mentionner",
   "status.mute_conversation": "Masquer la conversation",
   "status.open": "Déplier ce statut",
+  "status.pin": "Épingler sur le profil",
   "status.reblog": "Partager",
   "status.reblogged_by": "{name} a partagé :",
   "status.reply": "Répondre",
@@ -175,10 +184,11 @@
   "status.report": "Signaler @{name}",
   "status.sensitive_toggle": "Cliquer pour afficher",
   "status.sensitive_warning": "Contenu sensible",
-  "status.share": "Share",
+  "status.share": "Partager",
   "status.show_less": "Replier",
   "status.show_more": "Déplier",
   "status.unmute_conversation": "Ne plus masquer la conversation",
+  "status.unpin": "Retirer du profil",
   "tabs_bar.compose": "Composer",
   "tabs_bar.federated_timeline": "Fil public global",
   "tabs_bar.home": "Accueil",
@@ -188,6 +198,15 @@
   "upload_button.label": "Joindre un média",
   "upload_form.undo": "Annuler",
   "upload_progress.label": "Envoi en cours…",
+  "video.close": "Fermer la vidéo",
+  "video.exit_fullscreen": "Quitter plein écran",
+  "video.expand": "Agrandir la vidéo",
+  "video.fullscreen": "Plein écran",
+  "video.hide": "Masquer la vidéo",
+  "video.mute": "Couper le son",
+  "video.pause": "Pause",
+  "video.play": "Lecture",
+  "video.unmute": "Rétablir le son",
   "video_player.expand": "Agrandir la vidéo",
   "video_player.toggle_sound": "Activer/Désactiver le son",
   "video_player.toggle_visible": "Afficher/Cacher la vidéo",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 34266d8e1..f78c31a46 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -33,6 +33,7 @@
   "column.home": "בבית",
   "column.mutes": "השתקות",
   "column.notifications": "התראות",
+  "column.pins": "Pinned toot",
   "column.public": "בפרהסיה",
   "column_back_button.label": "חזרה",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "compose_form.lock_disclaimer": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.",
   "compose_form.lock_disclaimer.lock": "נעול",
   "compose_form.placeholder": "מה עובר לך בראש?",
-  "compose_form.privacy_disclaimer": "הודעתך הפרטית תשלח למשתמשים על {domains}. האם ניתן לסמוך על {domainsCount, plural, one {שרת זה} other {שרתים אלו}}? פרטיות ההודעה קיימת רק על שרתי מסטודון. אם {domains} {domainsCount, plural, one {הוא לא שרת מסטודון} other {הם לא שרתי מסטודון}}, לא יהיה שום סימן שההודעה פרטית, והוא עשוי להיות מקודם או להחשף למשתמשים שלא ברשימת היעד.",
   "compose_form.publish": "ללחוש",
   "compose_form.publish_loud": "לחצרץ!",
   "compose_form.sensitive": "סימון תוכן כרגיש",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "להשתיק את {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": "פעילות",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "דגלים",
   "emoji_button.food": "אוכל ושתיה",
   "emoji_button.label": "הוספת אמוג'י",
   "emoji_button.nature": "טבע",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "חפצים",
   "emoji_button.people": "אנשים",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "חיפוש...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "סמלים",
   "emoji_button.travel": "טיולים ואתרים",
   "empty_column.community": "טור הסביבה ריק. יש לפרסם משהו כדי שדברים יתרחילו להתגלגל!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "מידע נוסף",
   "navigation_bar.logout": "יציאה",
   "navigation_bar.mutes": "השתקות",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "העדפות",
   "navigation_bar.public_timeline": "ציר זמן בין-קהילתי",
   "notification.favourite": "חצרוצך חובב על ידי {name}",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
   "status.delete": "מחיקה",
+  "status.embed": "Embed",
   "status.favourite": "חיבוב",
   "status.load_more": "עוד",
   "status.media_hidden": "מדיה מוסתרת",
   "status.mention": "פניה אל @{name}",
   "status.mute_conversation": "השתקת שיחה",
   "status.open": "הרחבת הודעה",
+  "status.pin": "Pin on profile",
   "status.reblog": "הדהוד",
   "status.reblogged_by": "הודהד על ידי {name}",
   "status.reply": "תגובה",
@@ -179,6 +188,7 @@
   "status.show_less": "הראה פחות",
   "status.show_more": "הראה יותר",
   "status.unmute_conversation": "הסרת השתקת שיחה",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "חיבור",
   "tabs_bar.federated_timeline": "ציר זמן בין-קהילתי",
   "tabs_bar.home": "בבית",
@@ -188,6 +198,15 @@
   "upload_button.label": "הוספת מדיה",
   "upload_form.undo": "ביטול",
   "upload_progress.label": "עולה...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "הרחבת וידאו",
   "video_player.toggle_sound": "הפעלת\\ביטול שמע",
   "video_player.toggle_visible": "הפעלת\\ביטול תצוגה",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index f69b096d4..43fe95eb8 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -1,7 +1,7 @@
 {
   "account.block": "Blokiraj @{name}",
   "account.block_domain": "Sakrij sve sa {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.disclaimer_full": "Ovaj korisnik je sa druge instance. Ovaj broj bi mogao biti veći.",
   "account.edit_profile": "Uredi profil",
   "account.follow": "Slijedi",
   "account.followers": "Sljedbenici",
@@ -15,7 +15,7 @@
   "account.requested": "Čeka pristanak",
   "account.share": "Share @{name}'s profile",
   "account.unblock": "Deblokiraj @{name}",
-  "account.unblock_domain": "Otkrij {domain}",
+  "account.unblock_domain": "Poništi sakrivanje {domain}",
   "account.unfollow": "Prestani slijediti",
   "account.unmute": "Poništi utišavanje @{name}",
   "account.view_full_profile": "View full profile",
@@ -33,6 +33,7 @@
   "column.home": "Dom",
   "column.mutes": "Utišani korisnici",
   "column.notifications": "Notifikacije",
+  "column.pins": "Pinned toot",
   "column.public": "Federalni timeline",
   "column_back_button.label": "Natrag",
   "column_header.hide_settings": "Hide settings",
@@ -43,10 +44,9 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigacija",
   "column_subheading.settings": "Postavke",
-  "compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti i vidjeti tvoje postove namijenjene samo sljedbenicima.",
+  "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",
   "compose_form.placeholder": "Što ti je na umu?",
-  "compose_form.privacy_disclaimer": "Tvoj privatni status će biti dostavljen spomenutim korisnicima na {domains}. Vjeruješ li {domainsCount, plural, one {that server} drugim {those servers}}? Privatnost postova radi samo na Mastodon instancama. Ako {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, neće biti indikacije da je tvoj post privatan, i mogao bi biti podignut ili biti učinjen vidljivim na drugi način neželjenim primateljima.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Označi media sadržaj kao osjetljiv",
@@ -54,29 +54,35 @@
   "compose_form.spoiler_placeholder": "Upozorenje o sadržaju",
   "confirmation_modal.cancel": "Otkaži",
   "confirmations.block.confirm": "Blokiraj",
-  "confirmations.block.message": "Jesi li siguran da želiš blokirati {name}?",
+  "confirmations.block.message": "Želiš li sigurno blokirati {name}?",
   "confirmations.delete.confirm": "Obriši",
-  "confirmations.delete.message": "Jesi li siguran da želiš obrisati ovaj status?",
+  "confirmations.delete.message": "Želiš li stvarno obrisati ovaj status?",
   "confirmations.domain_block.confirm": "Sakrij cijelu domenu",
-  "confirmations.domain_block.message": "Jesi li zaista, zaista siguran da želiš blokirati sve sa {domain}? U većini slučajeva nekoliko ciljanih blokiranja ili utišavanja je dostatno i poželjnije.",
+  "confirmations.domain_block.message": "Jesi li zaista, zaista siguran da želiš potpuno blokirati {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
   "confirmations.mute.confirm": "Utišaj",
   "confirmations.mute.message": "Jesi li siguran da želiš utišati {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": "Aktivnost",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Zastave",
   "emoji_button.food": "Hrana & Piće",
   "emoji_button.label": "Umetni smajlije",
-  "emoji_button.nature": "Nature",
+  "emoji_button.nature": "Priroda",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objekti",
   "emoji_button.people": "Ljudi",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Traži...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Simboli",
-  "emoji_button.travel": "Putovanja i Mjesta",
+  "emoji_button.travel": "Putovanja & Mjesta",
   "empty_column.community": "Lokalni timeline je prazan. Napiši nešto javno kako bi pokrenuo stvari!",
   "empty_column.hashtag": "Još ne postoji ništa s ovim hashtagom.",
   "empty_column.home": "Još ne slijediš nikoga. Posjeti {public} ili koristi tražilicu kako bi počeo i upoznao druge korisnike.",
-  "empty_column.home.inactivity": "Tvoj home feed je prazan. Ako si neko vrijeme bio neaktivan, regenerirat će se uskoro.",
+  "empty_column.home.inactivity": "Tvoj home feed je prazan. Ako si neko vrijeme bio neaktivan, uskoro ćese regenerirati.",
   "empty_column.home.public_timeline": "javni timeline",
   "empty_column.notifications": "Još nemaš notifikacija. Komuniciraj sa drugima kako bi započeo razgovor.",
   "empty_column.public": "Ovdje nema ništa! Napiši nešto javno, ili ručno slijedi korisnike sa drugih instanci kako bi popunio",
@@ -86,11 +92,11 @@
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Počnimo",
   "getting_started.open_source_notice": "Mastodon je softver otvorenog koda. Možeš pridonijeti ili prijaviti probleme na GitHubu  {github}.",
-  "getting_started.userguide": "Vodič za korisnike",
+  "getting_started.userguide": "Upute za korištenje",
   "home.column_settings.advanced": "Napredno",
   "home.column_settings.basic": "Osnovno",
   "home.column_settings.filter_regex": "Filtriraj s regularnim izrazima",
-  "home.column_settings.show_reblogs": "Pokaži boosts",
+  "home.column_settings.show_reblogs": "Pokaži boostove",
   "home.column_settings.show_replies": "Pokaži odgovore",
   "home.settings": "Postavke Stupca",
   "lightbox.close": "Zatvori",
@@ -107,11 +113,12 @@
   "navigation_bar.info": "Više informacija",
   "navigation_bar.logout": "Odjavi se",
   "navigation_bar.mutes": "Utišani korisnici",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Postavke",
   "navigation_bar.public_timeline": "Federalni timeline",
   "notification.favourite": "{name} je lajkao tvoj status",
   "notification.follow": "{name} te sada slijedi",
-  "notification.mention": "{name} mentioned you",
+  "notification.mention": "{name} te je spomenuo",
   "notification.reblog": "{name} je podigao tvoj status",
   "notifications.clear": "Očisti notifikacije",
   "notifications.clear_confirmation": "Želiš li zaista obrisati sve svoje notifikacije?",
@@ -121,28 +128,28 @@
   "notifications.column_settings.mention": "Spominjanja:",
   "notifications.column_settings.push": "Push notifications",
   "notifications.column_settings.push_meta": "This device",
-  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.reblog": "Boostovi:",
   "notifications.column_settings.show": "Prikaži u stupcu",
   "notifications.column_settings.sound": "Sviraj zvuk",
   "onboarding.done": "Učinjeno",
-  "onboarding.next": "Sljedeća",
-  "onboarding.page_five.public_timelines": "The local timeline prikazuje javne postove svih na {domain}. Federalni timeline pokazuje javne postove svih sa {domain} domena koje slijediš. To je sjajan način da otkriješ nove ljude.",
-  "onboarding.page_four.home": "The home timeline prikazuje samo postove ljudi koje slijediš.",
-  "onboarding.page_four.notifications": "Stupac notifikacija pokazuje kada je netko u interakciji s tobom.",
-  "onboarding.page_one.federation": "Mastodon je mreža nezavisnih servera udruženih kako bi stvorili veću socijalnu mrežu. Te servere zovemo instance.",
-  "onboarding.page_one.handle": "Ti si na {domain}, tako da je tvoj potpuni opis {handle}",
-  "onboarding.page_one.welcome": "Dobro došli u Mastodon!",
+  "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.",
+  "onboarding.page_four.home": "The home timeline prikazuje postove ljudi koje slijediš.",
+  "onboarding.page_four.notifications": "Stupac za notifikacije pokazuje poruke drugih upućene tebi.",
+  "onboarding.page_one.federation": "Mastodon čini mreža  neovisnih servera udruženih u jednu veću socialnu mrežu. Te servere nazivamo instancama.",
+  "onboarding.page_one.handle": "Ti si na {domain}, i tvoja puna handle je {handle}",
+  "onboarding.page_one.welcome": "Dobro došli na Mastodon!",
   "onboarding.page_six.admin": "Administrator tvoje instance je {admin}.",
   "onboarding.page_six.almost_done": "Još malo pa gotovo...",
   "onboarding.page_six.appetoot": "Živjeli!",
   "onboarding.page_six.apps_available": "Postoje {apps} dostupne za iOS, Android i druge platforme.",
-  "onboarding.page_six.github": "Mastodon je besplatan softver otvorenog koda. Možeš prijaviti greške, zahtijevati mogućnosti, ili pridonijeti kodu na {github}.",
+  "onboarding.page_six.github": "Mastodon je besplatan softver otvorenog koda. You can report bugs, request features, or contribute to the code on {github}.",
   "onboarding.page_six.guidelines": "smjernice zajednice",
-  "onboarding.page_six.read_guidelines": "Molimo, pročitaj {domain}'s {guidelines}!",
+  "onboarding.page_six.read_guidelines": "Molimo pročitaj {domain}'s {guidelines}!",
   "onboarding.page_six.various_app": "mobilne aplikacije",
-  "onboarding.page_three.profile": "Uredi svoj profil mijenjanjem avatara, biografije i imena koje će biti prikazano. Naći ćeš i druge korisne postavke.",
-  "onboarding.page_three.search": "Koristi tražilicu kako bi pronašao ljude i sadržaj sa određenim hashtagovima, kao što su {illustration} i {introductions}. Da bi našao osobu koja nije na ovoj instanci, upotrijebi njihov puni opis.",
-  "onboarding.page_two.compose": "Piši postove u stupcu za njihovo sastavljanje. Možeš uploadati slike, promijeniti postavke privatnosti, i dodati upozorenja o sadržaju s ikonama ispod.",
+  "onboarding.page_three.profile": "Uredi svoj profil promjenom svog avatara, biografije, i imena. Ovdje ćeš isto tako pronaći i druge postavke.",
+  "onboarding.page_three.search": "Koristi tražilicu kako bi pronašao ljude i tražio hashtags, kao što su {illustration} i {introductions}. Kako bi pronašao osobu koja nije na ovoj instanci, upotrijebi njen pun handle.",
+  "onboarding.page_two.compose": "Piši postove u stupcu za sastavljanje. Možeš uploadati slike, promijeniti postavke privatnosti, i dodati upozorenja o sadržaju s ikonama ispod.",
   "onboarding.skip": "Preskoči",
   "privacy.change": "Podesi status privatnosti",
   "privacy.direct.long": "Prikaži samo spomenutim korisnicima",
@@ -160,14 +167,16 @@
   "search.placeholder": "Traži",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
-  "status.cannot_reblog": "Ovaj post ne može biti podignut",
+  "status.cannot_reblog": "Ovaj post ne može biti boostan",
   "status.delete": "Obriši",
+  "status.embed": "Embed",
   "status.favourite": "Označi omiljenim",
   "status.load_more": "Učitaj više",
   "status.media_hidden": "Sakriven media sadržaj",
   "status.mention": "Spomeni @{name}",
   "status.mute_conversation": "Utišaj razgovor",
   "status.open": "Proširi ovaj status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Podigni",
   "status.reblogged_by": "{name} je podigao",
   "status.reply": "Odgovori",
@@ -179,6 +188,7 @@
   "status.show_less": "Pokaži manje",
   "status.show_more": "Pokaži više",
   "status.unmute_conversation": "Poništi utišavanje razgovora",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Sastavi",
   "tabs_bar.federated_timeline": "Federalni",
   "tabs_bar.home": "Dom",
@@ -188,8 +198,17 @@
   "upload_button.label": "Dodaj media",
   "upload_form.undo": "Poništi",
   "upload_progress.label": "Uploadam...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Proširi video",
   "video_player.toggle_sound": "Toggle zvuk",
   "video_player.toggle_visible": "Preklopi vidljivost",
-  "video_player.video_error": "Video nije mogao biti prikazan"
+  "video_player.video_error": "Video ne može biti reproduciran"
 }
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 4d2a50963..f73295dca 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -33,6 +33,7 @@
   "column.home": "Kezdőlap",
   "column.mutes": "Muted users",
   "column.notifications": "Értesítések",
+  "column.pins": "Pinned toot",
   "column.public": "Nyilvános",
   "column_back_button.label": "Vissza",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "Mire gondolsz?",
-  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
   "compose_form.publish": "Tülk!",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Tartalom érzékenynek jelölése",
@@ -63,14 +63,20 @@
   "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!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Extended information",
   "navigation_bar.logout": "Kijelentkezés",
   "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Beállítások",
   "navigation_bar.public_timeline": "Nyilvános időfolyam",
   "notification.favourite": "{name} kedvencnek jelölte az állapotod",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Törlés",
+  "status.embed": "Embed",
   "status.favourite": "Kedvenc",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Említés",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Reblog",
   "status.reblogged_by": "{name} reblogolta",
   "status.reply": "Válasz",
@@ -179,6 +188,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Összeállítás",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Kezdőlap",
@@ -188,6 +198,15 @@
   "upload_button.label": "Média hozzáadása",
   "upload_form.undo": "Mégsem",
   "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Expand video",
   "video_player.toggle_sound": "Hang kapcsolása",
   "video_player.toggle_visible": "Toggle visibility",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 532739e3c..4d5f0a5d8 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -33,6 +33,7 @@
   "column.home": "Beranda",
   "column.mutes": "Pengguna dibisukan",
   "column.notifications": "Notifikasi",
+  "column.pins": "Pinned toot",
   "column.public": "Linimasa gabunggan",
   "column_back_button.label": "Kembali",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "dikunci",
   "compose_form.placeholder": "Apa yang ada di pikiran anda?",
-  "compose_form.privacy_disclaimer": "Status pribadi anda akan dikirim ke pengguna yang disebut dalam {domains}. Apa anda mempercayai {domainsCount, plural, one {server tersebut} other {server tersebut}}? Privasi postingan hanya bekerja dalam server Mastodon. Jika {domains} {domainsCount, plural, one {bukan server Mastodon} other {bukan server Mastodon}}, akan ada indikasi bahwa postingan anda adalah postingan pribadi, dan dapat di-boost atau dapat dilihat oleh orang lain.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Tandai media sensitif",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "Apa anda yakin ingin membisukan {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": "Aktivitas",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Bendera",
   "emoji_button.food": "Makanan & Minuman",
   "emoji_button.label": "Tambahkan emoji",
   "emoji_button.nature": "Alam",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Benda-benda",
   "emoji_button.people": "Orang",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Cari...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Simbol",
   "emoji_button.travel": "Tempat Wisata",
   "empty_column.community": "Linimasa lokal masih kosong. Tulis sesuatu secara publik dan buat roda berputar!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Informasi selengkapnya",
   "navigation_bar.logout": "Keluar",
   "navigation_bar.mutes": "Pengguna dibisukan",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Pengaturan",
   "navigation_bar.public_timeline": "Linimasa gabungan",
   "notification.favourite": "{name} menyukai status anda",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Hapus",
+  "status.embed": "Embed",
   "status.favourite": "Difavoritkan",
   "status.load_more": "Tampilkan semua",
   "status.media_hidden": "Media disembunyikan",
   "status.mention": "Balasan @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Tampilkan status ini",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "di-boost {name}",
   "status.reply": "Balas",
@@ -179,6 +188,7 @@
   "status.show_less": "Tampilkan lebih sedikit",
   "status.show_more": "Tampilkan semua",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Tulis",
   "tabs_bar.federated_timeline": "Gabungan",
   "tabs_bar.home": "Beranda",
@@ -188,6 +198,15 @@
   "upload_button.label": "Tambahkan media",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Mengunggah...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Tampilkan video",
   "video_player.toggle_sound": "Suara",
   "video_player.toggle_visible": "Tampilan",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index a5e363e40..d2c1ee73d 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -33,6 +33,7 @@
   "column.home": "Hemo",
   "column.mutes": "Celita uzeri",
   "column.notifications": "Savigi",
+  "column.pins": "Pinned toot",
   "column.public": "Federata tempolineo",
   "column_back_button.label": "Retro",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "Quo esas en tua spirito?",
-  "compose_form.privacy_disclaimer": "Tua privata mesajo livresos a mencionata uzeri en {domains}. Ka tu fidas {domainsCount, plural, one {ta servero} other {ta serveri}}? Privateso di mesaji funcionas nur en instaluri di Mastodon. Se {domains} {domainsCount, plural, one {ne esas instaluro di Mastodon} other {ne esas instaluri di Mastodon}}, esos nula indiko, ke tua mesajo esas privata, ed ol povos repetesar od altre divenar videbla da nedezirinda recevanti.",
   "compose_form.publish": "Siflar",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Markizar kontenajo kom trubliva",
@@ -63,14 +63,20 @@
   "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": "Insertar 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": "La lokala tempolineo esas vakua. Skribez ulo publike por iniciar la agiveso!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Detaloza informi",
   "navigation_bar.logout": "Ekirar",
   "navigation_bar.mutes": "Celita uzeri",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Preferi",
   "navigation_bar.public_timeline": "Federata tempolineo",
   "notification.favourite": "{name} favorizis tua mesajo",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Efacar",
+  "status.embed": "Embed",
   "status.favourite": "Favorizar",
   "status.load_more": "Kargar pluse",
   "status.media_hidden": "Kontenajo celita",
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Detaligar ca mesajo",
+  "status.pin": "Pin on profile",
   "status.reblog": "Repetar",
   "status.reblogged_by": "{name} repetita",
   "status.reply": "Respondar",
@@ -179,6 +188,7 @@
   "status.show_less": "Montrar mine",
   "status.show_more": "Montrar plue",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Kompozar",
   "tabs_bar.federated_timeline": "Federata",
   "tabs_bar.home": "Hemo",
@@ -188,6 +198,15 @@
   "upload_button.label": "Adjuntar kontenajo",
   "upload_form.undo": "Desfacar",
   "upload_progress.label": "Kargante...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Extensar video",
   "video_player.toggle_sound": "Acendar sono",
   "video_player.toggle_visible": "Chanjar videbleso",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 329eb82ca..33f0e7fdc 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -33,6 +33,7 @@
   "column.home": "Home",
   "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",
@@ -46,7 +47,6 @@
   "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": "A cosa stai pensando?",
-  "compose_form.privacy_disclaimer": "Il tuo status privato verrà condiviso con gli utenti menzionati su {domains}. Ti fidi di {domainsCount, plural, one {quel server} other {quei server}}? Le impostazioni sulla privacy valgono solo su server Mastodon. Se {domains} {domainsCount, plural, one {non è un server Mastodon} other {non sono server Mastodon}}, non ci saranno indicazioni sulla privacy del tuo status, e potrebbe essere condiviso o reso visibile a destinatari indesiderati.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Segnala file come sensibile",
@@ -63,14 +63,20 @@
   "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": "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",
   "empty_column.community": "La timeline locale è vuota. Condividi qualcosa pubblicamente per dare inizio alla festa!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Informazioni estese",
   "navigation_bar.logout": "Logout",
   "navigation_bar.mutes": "Utenti silenziati",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Impostazioni",
   "navigation_bar.public_timeline": "Timeline federata",
   "notification.favourite": "{name} ha apprezzato il tuo post",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Elimina",
+  "status.embed": "Embed",
   "status.favourite": "Apprezzato",
   "status.load_more": "Mostra di più",
   "status.media_hidden": "Allegato nascosto",
   "status.mention": "Nomina @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Espandi questo post",
+  "status.pin": "Pin on profile",
   "status.reblog": "Condividi",
   "status.reblogged_by": "{name} ha condiviso",
   "status.reply": "Rispondi",
@@ -179,6 +188,7 @@
   "status.show_less": "Mostra meno",
   "status.show_more": "Mostra di più",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Scrivi",
   "tabs_bar.federated_timeline": "Federazione",
   "tabs_bar.home": "Home",
@@ -188,6 +198,15 @@
   "upload_button.label": "Aggiungi file multimediale",
   "upload_form.undo": "Annulla",
   "upload_progress.label": "Sto caricando...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Espandi video",
   "video_player.toggle_sound": "Attiva suono",
   "video_player.toggle_visible": "Attiva visibilità",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 4c98086bb..c3d96baf3 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -33,6 +33,7 @@
   "column.home": "ホーム",
   "column.mutes": "ミュートしたユーザー",
   "column.notifications": "通知",
+  "column.pins": "固定されたトゥート",
   "column.public": "連合タイムライン",
   "column_back_button.label": "戻る",
   "column_header.hide_settings": "設定を隠す",
@@ -46,7 +47,6 @@
   "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
   "compose_form.lock_disclaimer.lock": "非公開",
   "compose_form.placeholder": "今なにしてる?",
-  "compose_form.privacy_disclaimer": "あなたの非公開トゥートは返信先ユーザーが所属する{domains}に送信されます。{domainsCount, plural, one {このサーバー} other {これらのサーバー}}は信頼できますか? 投稿のプライバシー保護はMastodonサーバー内でのみ有効です。{domains}がMastodonインスタンスでない場合、あなたの投稿がプライベートなものとして扱われず、ブーストされたり予期しないユーザーに見られる可能性があります。",
   "compose_form.publish": "トゥート",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "メディアを閲覧注意としてマークする",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "本当に{name}をミュートしますか?",
   "confirmations.unfollow.confirm": "フォロー解除",
   "confirmations.unfollow.message": "本当に{name}をフォロー解除しますか?",
+  "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。",
+  "embed.preview": "表示例:",
   "emoji_button.activity": "活動",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "国旗",
   "emoji_button.food": "食べ物",
   "emoji_button.label": "絵文字を追加",
   "emoji_button.nature": "自然",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "物",
   "emoji_button.people": "人々",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "検索...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "記号",
   "emoji_button.travel": "旅行と場所",
   "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
@@ -94,8 +100,8 @@
   "home.column_settings.show_replies": "返信表示",
   "home.settings": "カラム設定",
   "lightbox.close": "閉じる",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "次",
+  "lightbox.previous": "前",
   "loading_indicator.label": "読み込み中...",
   "media_gallery.toggle_visible": "表示切り替え",
   "missing_indicator.label": "見つかりません",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "このインスタンスについて",
   "navigation_bar.logout": "ログアウト",
   "navigation_bar.mutes": "ミュートしたユーザー",
+  "navigation_bar.pins": "固定されたトゥート",
   "navigation_bar.preferences": "ユーザー設定",
   "navigation_bar.public_timeline": "連合タイムライン",
   "notification.favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました",
@@ -159,15 +166,17 @@
   "report.target": "{target} を通報する",
   "search.placeholder": "検索",
   "search_results.total": "{count, number}件の結果",
-  "standalone.public_title": "連合タイムライン",
+  "standalone.public_title": "今こんな話をしています",
   "status.cannot_reblog": "この投稿はブーストできません",
   "status.delete": "削除",
+  "status.embed": "埋め込み",
   "status.favourite": "お気に入り",
   "status.load_more": "もっと見る",
   "status.media_hidden": "非表示のメディア",
   "status.mention": "返信",
   "status.mute_conversation": "会話をミュート",
   "status.open": "詳細を表示",
+  "status.pin": "プロフィールに固定表示",
   "status.reblog": "ブースト",
   "status.reblogged_by": "{name}さんにブーストされました",
   "status.reply": "返信",
@@ -179,6 +188,7 @@
   "status.show_less": "隠す",
   "status.show_more": "もっと見る",
   "status.unmute_conversation": "会話のミュートを解除",
+  "status.unpin": "プロフィールの固定表示を解除",
   "tabs_bar.compose": "投稿",
   "tabs_bar.federated_timeline": "連合",
   "tabs_bar.home": "ホーム",
@@ -188,6 +198,15 @@
   "upload_button.label": "メディアを追加",
   "upload_form.undo": "やり直す",
   "upload_progress.label": "アップロード中...",
+  "video.close": "動画を閉じる",
+  "video.exit_fullscreen": "全画面を終了する",
+  "video.expand": "動画を拡大する",
+  "video.fullscreen": "全画面",
+  "video.hide": "動画を閉じる",
+  "video.mute": "ミュート",
+  "video.pause": "一時停止",
+  "video.play": "再生",
+  "video.unmute": "ミュートを解除する",
   "video_player.expand": "動画の詳細",
   "video_player.toggle_sound": "音の切り替え",
   "video_player.toggle_visible": "表示切り替え",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 47d0d4087..c50bb2f34 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -33,6 +33,7 @@
   "column.home": "홈",
   "column.mutes": "뮤트 중인 사용자",
   "column.notifications": "알림",
+  "column.pins": "고정된 Toot",
   "column.public": "연합 타임라인",
   "column_back_button.label": "돌아가기",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
   "compose_form.lock_disclaimer.lock": "비공개",
   "compose_form.placeholder": "지금 무엇을 하고 있나요?",
-  "compose_form.privacy_disclaimer": "이 계정의 비공개 포스트는 멘션된 사용자가 소속된 {domains}으로 전송됩니다. {domainsCount, plural, one {이 서버를} other {이 서버들을}} 신뢰할 수 있습니까? 포스팅의 프라이버시 보호는 Mastodon 서버에서만 유효합니다. {domains}가 Mastodon 인스턴스가 아닐 경우, 이 투고가 사적인 것으로 취급되지 않은 채 부스트 되거나 원하지 않는 사용자에게 보여질 가능성이 있습니다.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "이 미디어를 민감한 미디어로 취급",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "정말로 {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": "활동",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "국기",
   "emoji_button.food": "음식",
   "emoji_button.label": "emoji를 추가",
   "emoji_button.nature": "자연",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "물건",
   "emoji_button.people": "사람들",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "검색...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "기호",
   "emoji_button.travel": "여행과 장소",
   "empty_column.community": "로컬 타임라인에 아무 것도 없습니다. 아무거나 적어 보세요!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "이 인스턴스에 대해서",
   "navigation_bar.logout": "로그아웃",
   "navigation_bar.mutes": "뮤트 중인 사용자",
+  "navigation_bar.pins": "고정된 Toot",
   "navigation_bar.preferences": "사용자 설정",
   "navigation_bar.public_timeline": "연합 타임라인",
   "notification.favourite": "{name}님이 즐겨찾기 했습니다",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
   "status.delete": "삭제",
+  "status.embed": "Embed",
   "status.favourite": "즐겨찾기",
   "status.load_more": "더 보기",
   "status.media_hidden": "미디어 숨겨짐",
   "status.mention": "답장",
   "status.mute_conversation": "이 대화를 뮤트",
   "status.open": "상세 정보 표시",
+  "status.pin": "Pin on profile",
   "status.reblog": "부스트",
   "status.reblogged_by": "{name}님이 부스트 했습니다",
   "status.reply": "답장",
@@ -179,6 +188,7 @@
   "status.show_less": "숨기기",
   "status.show_more": "더 보기",
   "status.unmute_conversation": "이 대화의 뮤트 해제하기",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "포스트",
   "tabs_bar.federated_timeline": "연합",
   "tabs_bar.home": "홈",
@@ -188,6 +198,15 @@
   "upload_button.label": "미디어 추가",
   "upload_form.undo": "재시도",
   "upload_progress.label": "업로드 중...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "동영상 자세히 보기",
   "video_player.toggle_sound": "소리 토글하기",
   "video_player.toggle_visible": "표시 전환",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 4d68c7992..c333bec70 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -12,7 +12,7 @@
   "account.mute": "Negeer @{name}",
   "account.posts": "Toots",
   "account.report": "Rapporteer @{name}",
-  "account.requested": "Wacht op goedkeuring",
+  "account.requested": "Wacht op goedkeuring. Klik om volgverzoek te annuleren.",
   "account.share": "Profiel van @{name} delen",
   "account.unblock": "Deblokkeer @{name}",
   "account.unblock_domain": "{domain} niet meer negeren",
@@ -33,11 +33,12 @@
   "column.home": "Start",
   "column.mutes": "Genegeerde gebruikers",
   "column.notifications": "Meldingen",
+  "column.pins": "Vastgezette toots",
   "column.public": "Globale tijdlijn",
   "column_back_button.label": "terug",
   "column_header.hide_settings": "Instellingen verbergen",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
+  "column_header.moveLeft_settings": "Kolom naar links verplaatsen",
+  "column_header.moveRight_settings": "Kolom naar rechts verplaatsen",
   "column_header.pin": "Vastmaken",
   "column_header.show_settings": "Instellingen tonen",
   "column_header.unpin": "Losmaken",
@@ -46,7 +47,6 @@
   "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.lock": "besloten",
   "compose_form.placeholder": "Wat wil je kwijt?",
-  "compose_form.privacy_disclaimer": "Jouw privétoot wordt afgeleverd aan de vermelde gebruikers op {domains}. Vertrouw jij {domainsCount, plural, one {die server} other {die servers}}? Het privé plaatsen van toots werkt alleen op Mastodon-servers. Wanneer {domains} {domainsCount, plural, one {geen Mastodon-server is} other {geen Mastodon-servers zijn}}, dan wordt er niet aangegeven dat de toot privé is, waardoor het kan worden geboost of op een andere manier zichtbaar wordt gemaakt voor mensen waarvoor het niet was bedoeld.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Media als gevoelig markeren (nsfw)",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "Weet je het zeker dat je {name} wilt negeren?",
   "confirmations.unfollow.confirm": "Ontvolgen",
   "confirmations.unfollow.message": "Weet je het zeker dat je {name} wilt ontvolgen?",
+  "embed.instructions": "Embed deze toot op jouw website, door de onderstaande code te kopiëren.",
+  "embed.preview": "Zo komt het eruit te zien:",
   "emoji_button.activity": "Activiteiten",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Vlaggen",
   "emoji_button.food": "Eten en drinken",
   "emoji_button.label": "Emoji toevoegen",
   "emoji_button.nature": "Natuur",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Voorwerpen",
   "emoji_button.people": "Mensen",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Zoeken...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Symbolen",
   "emoji_button.travel": "Reizen en plekken",
   "empty_column.community": "De lokale tijdlijn is nog leeg. Toot iets in het openbaar om de bal aan het rollen te krijgen!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Uitgebreide informatie",
   "navigation_bar.logout": "Afmelden",
   "navigation_bar.mutes": "Genegeerde gebruikers",
+  "navigation_bar.pins": "Vastgezette toots",
   "navigation_bar.preferences": "Instellingen",
   "navigation_bar.public_timeline": "Globale tijdlijn",
   "notification.favourite": "{name} markeerde jouw toot als favoriet",
@@ -162,12 +169,14 @@
   "standalone.public_title": "Een kijkje binnenin...",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
   "status.delete": "Verwijderen",
+  "status.embed": "Embed",
   "status.favourite": "Favoriet",
   "status.load_more": "Meer laden",
   "status.media_hidden": "Media verborgen",
   "status.mention": "Vermeld @{name}",
   "status.mute_conversation": "Negeer conversatie",
   "status.open": "Toot volledig tonen",
+  "status.pin": "Aan profielpagina vastmaken",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boostte",
   "status.reply": "Reageren",
@@ -179,6 +188,7 @@
   "status.show_less": "Minder tonen",
   "status.show_more": "Meer tonen",
   "status.unmute_conversation": "Conversatie niet meer negeren",
+  "status.unpin": "Van profielpagina losmaken",
   "tabs_bar.compose": "Schrijven",
   "tabs_bar.federated_timeline": "Globaal",
   "tabs_bar.home": "Start",
@@ -188,6 +198,15 @@
   "upload_button.label": "Media toevoegen",
   "upload_form.undo": "Ongedaan maken",
   "upload_progress.label": "Uploaden...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Video groter maken",
+  "video.fullscreen": "Volledig scherm",
+  "video.hide": "Video verbergen",
+  "video.mute": "Geluid uitschakelen",
+  "video.pause": "Pauze",
+  "video.play": "Afspelen",
+  "video.unmute": "Geluid inschakelen",
   "video_player.expand": "Video groter maken",
   "video_player.toggle_sound": "Geluid in-/uitschakelen",
   "video_player.toggle_visible": "Video wel/niet tonen",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 9453e65ff..d28190faf 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -33,6 +33,7 @@
   "column.home": "Hjem",
   "column.mutes": "Dempede brukere",
   "column.notifications": "Varsler",
+  "column.pins": "Pinned toot",
   "column.public": "Felles tidslinje",
   "column_back_button.label": "Tilbake",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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",
   "compose_form.placeholder": "Hva har du på hjertet?",
-  "compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ikke er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli fremhevd eller på annen måte bli synlig for uventede mottakere.",
   "compose_form.publish": "Tut",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Merk media som følsomt",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "Er du sikker på at du vil dempe {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": "Aktivitet",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Flagg",
   "emoji_button.food": "Mat og drikke",
   "emoji_button.label": "Sett inn emoji",
   "emoji_button.nature": "Natur",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objekter",
   "emoji_button.people": "Mennesker",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Søk...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Symboler",
   "emoji_button.travel": "Reise & steder",
   "empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Utvidet informasjon",
   "navigation_bar.logout": "Logg ut",
   "navigation_bar.mutes": "Dempede brukere",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Preferanser",
   "navigation_bar.public_timeline": "Felles tidslinje",
   "notification.favourite": "{name} likte din status",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Denne posten kan ikke fremheves",
   "status.delete": "Slett",
+  "status.embed": "Embed",
   "status.favourite": "Lik",
   "status.load_more": "Last mer",
   "status.media_hidden": "Media skjult",
   "status.mention": "Nevn @{name}",
   "status.mute_conversation": "Demp samtale",
   "status.open": "Utvid denne statusen",
+  "status.pin": "Pin on profile",
   "status.reblog": "Fremhev",
   "status.reblogged_by": "Fremhevd av {name}",
   "status.reply": "Svar",
@@ -179,6 +188,7 @@
   "status.show_less": "Vis mindre",
   "status.show_more": "Vis mer",
   "status.unmute_conversation": "Ikke demp samtale",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Komponer",
   "tabs_bar.federated_timeline": "Felles",
   "tabs_bar.home": "Hjem",
@@ -188,6 +198,15 @@
   "upload_button.label": "Legg til media",
   "upload_form.undo": "Angre",
   "upload_progress.label": "Laster opp...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Utvid video",
   "video_player.toggle_sound": "Veksle lyd",
   "video_player.toggle_visible": "Veksle synlighet",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index e2a5d7c59..8e9d06642 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -12,7 +12,7 @@
   "account.mute": "Rescondre @{name}",
   "account.posts": "Estatuts",
   "account.report": "Senhalar @{name}",
-  "account.requested": "Invitacion mandada",
+  "account.requested": "Invitacion mandada. Clicatz per anullar.",
   "account.share": "Partejar lo perfil a @{name}",
   "account.unblock": "Desblocar @{name}",
   "account.unblock_domain": "Desblocar {domain}",
@@ -33,6 +33,7 @@
   "column.home": "Acuèlh",
   "column.mutes": "Personas en silenci",
   "column.notifications": "Notificacions",
+  "column.pins": "Tuts penjats",
   "column.public": "Flux public global",
   "column_back_button.label": "Tornar",
   "column_header.hide_settings": "Amagar los paramètres",
@@ -45,47 +46,52 @@
   "column_subheading.settings": "Paramètres",
   "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.lock_disclaimer.lock": "clavat",
-  "compose_form.placeholder": "A de qué pensatz ?",
-  "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
+  "compose_form.placeholder": "A de qué pensatz ?",
   "compose_form.publish": "Tut",
-  "compose_form.publish_loud": "{publish} !",
+  "compose_form.publish_loud": "{publish} !",
   "compose_form.sensitive": "Marcar lo mèdia coma sensible",
   "compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment",
   "compose_form.spoiler_placeholder": "Escrivètz l’avertiment aquí",
   "confirmation_modal.cancel": "Anullar",
   "confirmations.block.confirm": "Blocar",
-  "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
+  "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
   "confirmations.delete.confirm": "Suprimir",
-  "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
+  "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
   "confirmations.domain_block.confirm": "Amagar tot lo domeni",
-  "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
+  "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
   "confirmations.mute.confirm": "Metre en silenci",
-  "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
+  "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
   "confirmations.unfollow.confirm": "Quitar de sègre",
-  "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?",
+  "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?",
+  "embed.instructions": "Embarcar aqueste estatut per lo far veire sus un site Internet en copiar lo còdi çai-jos.",
+  "embed.preview": "Semblarà aquò : ",
   "emoji_button.activity": "Activitats",
+  "emoji_button.custom": "Personalizats",
   "emoji_button.flags": "Drapèus",
   "emoji_button.food": "Beure e manjar",
   "emoji_button.label": "Inserir un emoji",
   "emoji_button.nature": "Natura",
+  "emoji_button.not_found": "Cap emoji ! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objèctes",
   "emoji_button.people": "Gents",
+  "emoji_button.recent": "Sovent utilizats",
   "emoji_button.search": "Cercar…",
+  "emoji_button.search_results": "Resultat de recèrca",
   "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.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
   "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag",
   "empty_column.home": "Pel moment seguètz pas degun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
   "empty_column.home.inactivity": "Vòstra pagina d’acuèlh es voida. Se sètz estat inactiu per un moment, serà tornada generar per vos dins una estona.",
   "empty_column.home.public_timeline": "lo flux public",
   "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.",
+  "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.reject": "Regetar",
   "getting_started.appsshort": "Apps",
   "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.",
+  "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.",
   "getting_started.userguide": "Guida d’utilizacion",
   "home.column_settings.advanced": "Avançat",
   "home.column_settings.basic": "Basic",
@@ -107,38 +113,39 @@
   "navigation_bar.info": "Mai informacions",
   "navigation_bar.logout": "Desconnexion",
   "navigation_bar.mutes": "Personas rescondudas",
+  "navigation_bar.pins": "Tuts penjats",
   "navigation_bar.preferences": "Preferéncias",
   "navigation_bar.public_timeline": "Flux public global",
-  "notification.favourite": "{name} a ajustat a sos favorits :",
+  "notification.favourite": "{name} a ajustat a sos favorits :",
   "notification.follow": "{name} vos sèc",
-  "notification.mention": "{name} vos a mencionat :",
-  "notification.reblog": "{name} a partejat vòstre estatut :",
+  "notification.mention": "{name} vos a mencionat :",
+  "notification.reblog": "{name} a partejat vòstre estatut :",
   "notifications.clear": "Escafar",
-  "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?",
+  "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?",
   "notifications.column_settings.alert": "Notificacions localas",
-  "notifications.column_settings.favourite": "Favorits :",
-  "notifications.column_settings.follow": "Nòus seguidors :",
-  "notifications.column_settings.mention": "Mencions :",
+  "notifications.column_settings.favourite": "Favorits :",
+  "notifications.column_settings.follow": "Nòus seguidors :",
+  "notifications.column_settings.mention": "Mencions :",
   "notifications.column_settings.push": "Notificacions",
   "notifications.column_settings.push_meta": "Aqueste periferic",
-  "notifications.column_settings.reblog": "Partatges :",
+  "notifications.column_settings.reblog": "Partatges :",
   "notifications.column_settings.show": "Mostrar dins la colomna",
   "notifications.column_settings.sound": "Emetre un son",
-  "onboarding.done": "Fach",
+  "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 tot lo mond sus {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.",
+  "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.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 bastir un malhum ma larg. Òm los apèla instàncias.",
+  "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum mai larg. Òm los apèla instàncias.",
   "onboarding.page_one.handle": "Sètz sus {domain}, doncas vòstre identificant complet es {handle}",
-  "onboarding.page_one.welcome": "Benvengut a Mastodon !",
+  "onboarding.page_one.welcome": "Benvengut a Mastodon !",
   "onboarding.page_six.admin": "Vòstre administrator d’instància es {admin}.",
   "onboarding.page_six.almost_done": "Gaireben acabat…",
-  "onboarding.page_six.appetoot": "Bon Appetut!",
+  "onboarding.page_six.appetoot": "Bon Appetut !",
   "onboarding.page_six.apps_available": "I a d’aplicacions per mobil per iOS, Android e mai.",
   "onboarding.page_six.github": "Mastodon es un logicial liure e open-source.  Podètz senhalar de bugs, demandar de foncionalitats e contribuir al còdi sus {github}.",
   "onboarding.page_six.guidelines": "guida de la comunitat",
-  "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !",
+  "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 complet.",
@@ -162,14 +169,16 @@
   "standalone.public_title": "Una ulhada dedins…",
   "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
   "status.delete": "Escafar",
+  "status.embed": "Embarcar",
   "status.favourite": "Apondre als favorits",
   "status.load_more": "Cargar mai",
   "status.media_hidden": "Mèdia rescondut",
   "status.mention": "Mencionar",
   "status.mute_conversation": "Rescondre la conversacion",
   "status.open": "Desplegar aqueste estatut",
+  "status.pin": "Penjar al perfil",
   "status.reblog": "Partejar",
-  "status.reblogged_by": "{name} a partejat :",
+  "status.reblogged_by": "{name} a partejat :",
   "status.reply": "Respondre",
   "status.replyAll": "Respondre a la conversacion",
   "status.report": "Senhalar @{name}",
@@ -179,6 +188,7 @@
   "status.show_less": "Tornar plegar",
   "status.show_more": "Desplegar",
   "status.unmute_conversation": "Conversacions amb silenci levat",
+  "status.unpin": "Tirar del perfil",
   "tabs_bar.compose": "Compausar",
   "tabs_bar.federated_timeline": "Flux public global",
   "tabs_bar.home": "Acuèlh",
@@ -188,6 +198,15 @@
   "upload_button.label": "Ajustar un mèdia",
   "upload_form.undo": "Anullar",
   "upload_progress.label": "Mandadís…",
+  "video.close": "Tampar la vidèo",
+  "video.exit_fullscreen": "Sortir plen ecran",
+  "video.expand": "Agrandir la vidèo",
+  "video.fullscreen": "Ecran complet",
+  "video.hide": "Amagar la vidèo",
+  "video.mute": "Copar lo son",
+  "video.pause": "Pausa",
+  "video.play": "Lectura",
+  "video.unmute": "Restablir lo son",
   "video_player.expand": "Mostrar la vidèo",
   "video_player.toggle_sound": "Activar/Desactivar lo son",
   "video_player.toggle_visible": "Mostrar/Rescondre la vidèo",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index c42721f64..35b1a3101 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -10,9 +10,9 @@
   "account.media": "Media",
   "account.mention": "Wspomnij o @{name}",
   "account.mute": "Wycisz @{name}",
-  "account.posts": "Posty",
+  "account.posts": "Wpisy",
   "account.report": "Zgłoś @{name}",
-  "account.requested": "Oczekująca prośba",
+  "account.requested": "Oczekująca prośba, kliknij aby anulować",
   "account.share": "Udostępnij profil @{name}",
   "account.unblock": "Odblokuj @{name}",
   "account.unblock_domain": "Odblokuj domenę {domain}",
@@ -33,6 +33,7 @@
   "column.home": "Strona główna",
   "column.mutes": "Wyciszeni użytkownicy",
   "column.notifications": "Powiadomienia",
+  "column.pins": "Przypięte wpisy",
   "column.public": "Globalna oś czasu",
   "column_back_button.label": "Wróć",
   "column_header.hide_settings": "Ukryj ustawienia",
@@ -43,10 +44,9 @@
   "column_header.unpin": "Cofnij przypięcie",
   "column_subheading.navigation": "Nawigacja",
   "column_subheading.settings": "Ustawienia",
-  "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje posty przeznaczone tylko dla śledzących.",
+  "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",
   "compose_form.placeholder": "Co Ci chodzi po głowie?",
-  "compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.",
   "compose_form.publish": "Wyślij",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Oznacz treści jako wrażliwe",
@@ -63,18 +63,24 @@
   "confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
   "confirmations.unfollow.confirm": "Przestań śledzić",
   "confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?",
+  "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.",
+  "embed.preview": "Tak będzie to wyglądać:",
   "emoji_button.activity": "Aktywność",
+  "emoji_button.custom": "Niestandardowe",
   "emoji_button.flags": "Flagi",
   "emoji_button.food": "Żywność i napoje",
   "emoji_button.label": "Wstaw emoji",
   "emoji_button.nature": "Natura",
+  "emoji_button.not_found": "Brak emoji!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objekty",
   "emoji_button.people": "Ludzie",
-  "emoji_button.search": "Szukaj...",
+  "emoji_button.recent": "Najczęściej używane",
+  "emoji_button.search": "Szukaj…",
+  "emoji_button.search_results": "Wyniki wyszukiwania",
   "emoji_button.symbols": "Symbole",
   "emoji_button.travel": "Podróże i miejsca",
-  "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
-  "empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
+  "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
+  "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
   "empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
   "empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
   "empty_column.home.public_timeline": "publiczna oś czasu",
@@ -85,7 +91,7 @@
   "getting_started.appsshort": "Aplikacje",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Naucz się korzystać",
-  "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj {github}.",
+  "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj: {github}.",
   "getting_started.userguide": "Podręcznik użytkownika",
   "home.column_settings.advanced": "Zaawansowane",
   "home.column_settings.basic": "Podstawowe",
@@ -96,7 +102,7 @@
   "lightbox.close": "Zamknij",
   "lightbox.next": "Następne",
   "lightbox.previous": "Poprzednie",
-  "loading_indicator.label": "Ładowanie...",
+  "loading_indicator.label": "Ładowanie…",
   "media_gallery.toggle_visible": "Przełącz widoczność",
   "missing_indicator.label": "Nie znaleziono",
   "navigation_bar.blocks": "Zablokowani użytkownicy",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Szczegółowe informacje",
   "navigation_bar.logout": "Wyloguj",
   "navigation_bar.mutes": "Wyciszeni użytkownicy",
+  "navigation_bar.pins": "Przypięte wpisy",
   "navigation_bar.preferences": "Preferencje",
   "navigation_bar.public_timeline": "Oś czasu federacji",
   "notification.favourite": "{name} dodał Twój status do ulubionych",
@@ -116,12 +123,12 @@
   "notifications.clear": "Wyczyść powiadomienia",
   "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
   "notifications.column_settings.alert": "Powiadomienia na pulpicie",
-  "notifications.column_settings.favourite": "Ulubione:",
+  "notifications.column_settings.favourite": "Dodanie do ulubionych:",
   "notifications.column_settings.follow": "Nowi śledzący:",
-  "notifications.column_settings.mention": "Wspomniali:",
+  "notifications.column_settings.mention": "Wspomnienia:",
   "notifications.column_settings.push": "Powiadomienia push",
   "notifications.column_settings.push_meta": "To urządzenie",
-  "notifications.column_settings.reblog": "Podbili:",
+  "notifications.column_settings.reblog": "Podbicia:",
   "notifications.column_settings.show": "Pokaż w kolumnie",
   "notifications.column_settings.sound": "Odtwarzaj dźwięk",
   "onboarding.done": "Gotowe",
@@ -142,32 +149,34 @@
   "onboarding.page_six.various_app": "aplikacje mobilne",
   "onboarding.page_three.profile": "Edytuj profil, aby zmienić obraz profilowy, biografię, wyświetlaną nazwę i inne ustawienia.",
   "onboarding.page_three.search": "Użyj paska wyszukiwania aby znaleźć ludzi i hashtagi, takie jak {illustration} i {introductions}. Aby znaleźć osobę spoza tej instancji, musisz użyć pełnego adresu.",
-  "onboarding.page_two.compose": "Napisz posty, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.",
+  "onboarding.page_two.compose": "Utwórz wpisy, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.",
   "onboarding.skip": "Pomiń",
-  "privacy.change": "Dostosuj widoczność postów",
-  "privacy.direct.long": "Widoczne tylko dla oznaczonych",
+  "privacy.change": "Dostosuj widoczność wpisów",
+  "privacy.direct.long": "Widoczny tylko dla wspomnianych",
   "privacy.direct.short": "Bezpośrednio",
-  "privacy.private.long": "Widoczne tylko dla śledzących",
-  "privacy.private.short": "Tylko śledzący",
-  "privacy.public.long": "Widoczne na publicznych osiach czasu",
-  "privacy.public.short": "Publiczne",
-  "privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
-  "privacy.unlisted.short": "Niewidoczne",
+  "privacy.private.long": "Widoczny tylko dla osób, które Cię śledzą",
+  "privacy.private.short": "Tylko dla śledzących",
+  "privacy.public.long": "Widoczny na publicznych osiach czasu",
+  "privacy.public.short": "Publiczny",
+  "privacy.unlisted.long": "Niewidoczny na publicznych osiach czasu",
+  "privacy.unlisted.short": "Niewidoczny",
   "reply_indicator.cancel": "Anuluj",
   "report.placeholder": "Dodatkowe komentarze",
   "report.submit": "Wyślij",
   "report.target": "Zgłaszanie {target}",
   "search.placeholder": "Szukaj",
   "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
-  "standalone.public_title": "Spojrzenie wgłąb…",
-  "status.cannot_reblog": "Ten post nie może zostać podbity",
+  "standalone.public_title": "Spojrzenie w głąb…",
+  "status.cannot_reblog": "Ten wpis nie może zostać podbity",
   "status.delete": "Usuń",
+  "status.embed": "Osadź",
   "status.favourite": "Ulubione",
   "status.load_more": "Załaduj więcej",
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
   "status.mute_conversation": "Wycisz konwersację",
   "status.open": "Rozszerz ten status",
+  "status.pin": "Przypnij do profilu",
   "status.reblog": "Podbij",
   "status.reblogged_by": "{name} podbił",
   "status.reply": "Odpowiedz",
@@ -178,7 +187,8 @@
   "status.share": "Udostępnij",
   "status.show_less": "Pokaż mniej",
   "status.show_more": "Pokaż więcej",
-  "status.unmute_conversation": "Cofnij wyciezenie konwersacji",
+  "status.unmute_conversation": "Cofnij wyciszenie konwersacji",
+  "status.unpin": "Odepnij z profilu",
   "tabs_bar.compose": "Napisz",
   "tabs_bar.federated_timeline": "Globalne",
   "tabs_bar.home": "Strona główna",
@@ -188,7 +198,16 @@
   "upload_button.label": "Dodaj zawartość multimedialną",
   "upload_form.undo": "Cofnij",
   "upload_progress.label": "Wysyłanie",
-  "video_player.expand": "Przełącz wideo",
+  "video.close": "Zamknij film",
+  "video.exit_fullscreen": "Opuść tryb pełnoekranowy",
+  "video.expand": "Rozszerz film",
+  "video.fullscreen": "Pełny ekran",
+  "video.hide": "Ukryj film",
+  "video.mute": "Wycisz",
+  "video.pause": "Pauzuj",
+  "video.play": "Odtwórz",
+  "video.unmute": "Cofnij wyciszenie",
+  "video_player.expand": "Rozszerz film",
   "video_player.toggle_sound": "Przełącz dźwięk",
   "video_player.toggle_visible": "Przełącz widoczność",
   "video_player.video_error": "Nie można odtworzyć pliku wideo"
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 55d2f05de..187343e83 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -1,193 +1,212 @@
 {
   "account.block": "Bloquear @{name}",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "Esconder tudo de {domain}",
+  "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de maneira incompleta.",
   "account.edit_profile": "Editar perfil",
   "account.follow": "Seguir",
   "account.followers": "Seguidores",
   "account.follows": "Segue",
-  "account.follows_you": "É teu seguidor",
-  "account.media": "Media",
+  "account.follows_you": "Segue você",
+  "account.media": "Mídia",
   "account.mention": "Mencionar @{name}",
   "account.mute": "Silenciar @{name}",
   "account.posts": "Posts",
   "account.report": "Denunciar @{name}",
-  "account.requested": "A aguardar aprovação",
-  "account.share": "Share @{name}'s profile",
-  "account.unblock": "Não bloquear @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.requested": "Aguardando aprovação. Clique para cancelar a solicitação.",
+  "account.share": "Compartilhar perfil de @{name}",
+  "account.unblock": "Desbloquear @{name}",
+  "account.unblock_domain": "Desbloquear {domain}",
   "account.unfollow": "Deixar de seguir",
   "account.unmute": "Não silenciar @{name}",
-  "account.view_full_profile": "View full profile",
-  "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "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": "Utilizadores Bloqueados",
+  "account.view_full_profile": "Ver perfil completo",
+  "boost_modal.combo": "Você pode pressionar {combo} para ignorar este diálogo na próxima vez",
+  "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.",
+  "bundle_column_error.retry": "Tente novamente",
+  "bundle_column_error.title": "Erro de rede",
+  "bundle_modal_error.close": "Fechar",
+  "bundle_modal_error.message": "Algo de errado aconteceu enquanto este componente era carregado.",
+  "bundle_modal_error.retry": "Tente novamente",
+  "column.blocks": "Usuários bloqueados",
   "column.community": "Local",
   "column.favourites": "Favoritos",
-  "column.follow_requests": "Seguidores Pendentes",
-  "column.home": "Home",
-  "column.mutes": "Utilizadores silenciados",
+  "column.follow_requests": "Seguidores pendentes",
+  "column.home": "Página inicial",
+  "column.mutes": "Usuários silenciados",
   "column.notifications": "Notificações",
+  "column.pins": "Postagens fixadas",
   "column.public": "Global",
   "column_back_button.label": "Voltar",
-  "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_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "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": "Em que estás a pensar?",
-  "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.",
+  "column_header.hide_settings": "Esconder configurações",
+  "column_header.moveLeft_settings": "Mover coluna para a esquerda",
+  "column_header.moveRight_settings": "Mover coluna para a direita",
+  "column_header.pin": "Fixar",
+  "column_header.show_settings": "Mostrar configurações",
+  "column_header.unpin": "Desafixar",
+  "column_subheading.navigation": "Navegação",
+  "column_subheading.settings": "Configurações",
+  "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": "trancado",
+  "compose_form.placeholder": "No que você está pensando?",
   "compose_form.publish": "Publicar",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive": "Marcar media como conteúdo sensível",
-  "compose_form.spoiler": "Esconder texto com aviso",
+  "compose_form.sensitive": "Marcar mídia como conteúdo sensível",
+  "compose_form.spoiler": "Esconder texto com aviso de conteúdo",
   "compose_form.spoiler_placeholder": "Aviso de conteúdo",
-  "confirmation_modal.cancel": "Cancel",
-  "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
-  "confirmations.delete.confirm": "Delete",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
-  "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}?",
-  "emoji_button.activity": "Activity",
-  "emoji_button.flags": "Flags",
-  "emoji_button.food": "Food & Drink",
+  "confirmation_modal.cancel": "Cancelar",
+  "confirmations.block.confirm": "Bloquear",
+  "confirmations.block.message": "Você tem certeza de que quer bloquear {name}?",
+  "confirmations.delete.confirm": "Excluir",
+  "confirmations.delete.message": "Você tem certeza de que quer excluir esta postagem?",
+  "confirmations.domain_block.confirm": "Esconder o domínio inteiro",
+  "confirmations.domain_block.message": "Você quer mesmo bloquear {domain} inteiro? Na maioria dos casos, silenciar ou bloquear alguns usuários é o suficiente e o recomendado.",
+  "confirmations.mute.confirm": "Silenciar",
+  "confirmations.mute.message": "Você tem certeza de que quer silenciar {name}?",
+  "confirmations.unfollow.confirm": "Deixar de seguir",
+  "confirmations.unfollow.message": "Você tem certeza de que quer deixar de seguir {name}?",
+  "embed.instructions": "Incorpore esta postagem em seu site copiando o código abaixo:",
+  "embed.preview": "Aqui está uma previsão de como ficará:",
+  "emoji_button.activity": "Atividades",
+  "emoji_button.custom": "Custom",
+  "emoji_button.flags": "Bandeiras",
+  "emoji_button.food": "Comidas & Bebidas",
   "emoji_button.label": "Inserir Emoji",
-  "emoji_button.nature": "Nature",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.search": "Search...",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
-  "empty_column.community": "Ainda não existem conteúdo local para mostrar!",
-  "empty_column.hashtag": "Ainda não existe qualquer conteúdo com essa hashtag",
-  "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.",
-  "empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
+  "emoji_button.nature": "Natureza",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Objetos",
+  "emoji_button.people": "Pessoas",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "Buscar...",
+  "emoji_button.search_results": "Search results",
+  "emoji_button.symbols": "Símbolos",
+  "emoji_button.travel": "Viagens & Lugares",
+  "empty_column.community": "A timeline local está vazia. Escreva algo publicamente para começar!",
+  "empty_column.hashtag": "Ainda não há qualquer conteúdo com essa hashtag",
+  "empty_column.home": "Você ainda não segue usuário algo. Visite a timeline {public} ou use o buscador para procurar e conhecer outros usuários.",
+  "empty_column.home.inactivity": "A sua página inicial está vazia. Se você esteve inativo por um tempo, ela irá se regenerar em alguns intantes.",
   "empty_column.home.public_timeline": "global",
-  "empty_column.notifications": "Não tens notificações. Interage com outros utilizadores para iniciar uma conversa.",
-  "empty_column.public": "Não há nada aqui! Escreve algo publicamente ou segue outros utilizadores para ver aqui os conteúdos públicos.",
+  "empty_column.notifications": "Você ainda não possui notificações. Interaja com outros usuários para começar a conversar!",
+  "empty_column.public": "Não há nada aqui! Escreva algo publicamente ou siga manualmente usuários de outras instâncias.",
   "follow_request.authorize": "Autorizar",
   "follow_request.reject": "Rejeitar",
   "getting_started.appsshort": "Apps",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Primeiros passos",
-  "getting_started.open_source_notice": "Mastodon é software de fonte aberta. Podes contribuir ou repostar problemas no GitHub do projecto: {github}.",
-  "getting_started.userguide": "User Guide",
+  "getting_started.open_source_notice": "Mastodon é um software de código aberto. Você pode contribuir ou reportar problemas na página do GitHub do projeto: {github}.",
+  "getting_started.userguide": "Guia de usuário",
   "home.column_settings.advanced": "Avançado",
   "home.column_settings.basic": "Básico",
   "home.column_settings.filter_regex": "Filtrar com uma expressão regular",
-  "home.column_settings.show_reblogs": "Mostrar as partilhas",
+  "home.column_settings.show_reblogs": "Mostrar compartilhamentos",
   "home.column_settings.show_replies": "Mostrar as respostas",
-  "home.settings": "Parâmetros da listagem",
+  "home.settings": "Configurações de colunas",
   "lightbox.close": "Fechar",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "Próximo",
+  "lightbox.previous": "Anterior",
   "loading_indicator.label": "Carregando...",
   "media_gallery.toggle_visible": "Esconder/Mostrar",
   "missing_indicator.label": "Não encontrado",
-  "navigation_bar.blocks": "Utilizadores bloqueados",
+  "navigation_bar.blocks": "Usuários bloqueados",
   "navigation_bar.community_timeline": "Local",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favoritos",
   "navigation_bar.follow_requests": "Seguidores pendentes",
   "navigation_bar.info": "Mais informações",
   "navigation_bar.logout": "Sair",
-  "navigation_bar.mutes": "Utilizadores silenciados",
+  "navigation_bar.mutes": "Usuários silenciados",
+  "navigation_bar.pins": "Postagens fixadas",
   "navigation_bar.preferences": "Preferências",
   "navigation_bar.public_timeline": "Global",
-  "notification.favourite": "{name} adicionou o teu post aos favoritos",
-  "notification.follow": "{name} seguiu-te",
-  "notification.mention": "{name} mencionou-te",
-  "notification.reblog": "{name} partilhou o teu post",
+  "notification.favourite": "{name} adicionou a sua postagem aos favoritos",
+  "notification.follow": "{name} te seguiu",
+  "notification.mention": "{name} te mencionou",
+  "notification.reblog": "{name} compartilhou a sua postagem",
   "notifications.clear": "Limpar notificações",
-  "notifications.clear_confirmation": "Queres mesmo limpar todas as notificações?",
+  "notifications.clear_confirmation": "Você tem certeza de que quer limpar todas as suas notificações permanentemente?",
   "notifications.column_settings.alert": "Notificações no computador",
   "notifications.column_settings.favourite": "Favoritos:",
   "notifications.column_settings.follow": "Novos seguidores:",
   "notifications.column_settings.mention": "Menções:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
-  "notifications.column_settings.reblog": "Partilhas:",
+  "notifications.column_settings.push": "Enviar notificações",
+  "notifications.column_settings.push_meta": "Este aparelho",
+  "notifications.column_settings.reblog": "Compartilhamento:",
   "notifications.column_settings.show": "Mostrar nas colunas",
   "notifications.column_settings.sound": "Reproduzir som",
-  "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.handle": "You are on {domain}, so your full handle is {handle}",
-  "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.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.",
+  "onboarding.page_four.home": "A página inicial mostra postagens de pessoas que você segue.",
+  "onboarding.page_four.notifications": "A coluna de notificações te mostra quando alguém interage com você.",
+  "onboarding.page_one.federation": "Mastodon é uma rede d servidores independentes se juntando para fazer uma grande rede social. Nós chamamos estes servidores de instâncias.",
+  "onboarding.page_one.handle": "Você está no {domain}, então o seu nome de usuário completo é {handle}",
+  "onboarding.page_one.welcome": "Seja bem-vindo(a) ao Mastodon!",
+  "onboarding.page_six.admin": "O administrador de sua instância é {admin}.",
+  "onboarding.page_six.almost_done": "Quase acabando...",
   "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",
+  "onboarding.page_six.apps_available": "Há {apps} disponíveis para iOS, Android e outras plataformas.",
+  "onboarding.page_six.github": "Mastodon é um software gratuito e de código aberto. Você pode reportar bugs, prequisitar novas funções ou contribuir para o código no {github}.",
+  "onboarding.page_six.guidelines": "diretrizes da comunidade",
+  "onboarding.page_six.read_guidelines": "Por favor, leia as {guidelines} do {domain}!",
+  "onboarding.page_six.various_app": "aplicativos móveis",
+  "onboarding.page_three.profile": "Edite o seu perfil para mudar o seu o seu avatar, bio e nome de exibição. No menu de configurações, você também encontrará outras preferências.",
+  "onboarding.page_three.search": "Use a barra de buscas para encontrar pessoas e consultar hashtahs, como #illustrations e #introductions. Para procurar por uma pessoa que não estiver nesta instância, use o nome de usuário completo dela.",
+  "onboarding.page_two.compose": "Escreva postagens na coluna de escrita. Você pode hospedar imagens, mudar as configurações de privacidade e adicionar alertas de conteúdo através dos ícones abaixo.",
+  "onboarding.skip": "Pular",
   "privacy.change": "Ajustar a privacidade da mensagem",
-  "privacy.direct.long": "Apenas para utilizadores mencionados",
-  "privacy.direct.short": "Directo",
-  "privacy.private.long": "Apenas para os seguidores",
-  "privacy.private.short": "Privado",
+  "privacy.direct.long": "Apenas para usuários mencionados",
+  "privacy.direct.short": "Direta",
+  "privacy.private.long": "Apenas para seus seguidores",
+  "privacy.private.short": "Privada",
   "privacy.public.long": "Publicar em todos os feeds",
-  "privacy.public.short": "Público",
-  "privacy.unlisted.long": "Não publicar nos feeds públicos",
-  "privacy.unlisted.short": "Não listar",
+  "privacy.public.short": "Pública",
+  "privacy.unlisted.long": "Não publicar em feeds públicos",
+  "privacy.unlisted.short": "Não listada",
   "reply_indicator.cancel": "Cancelar",
   "report.placeholder": "Comentários adicionais",
   "report.submit": "Enviar",
   "report.target": "Denunciar",
   "search.placeholder": "Pesquisar",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
-  "standalone.public_title": "A look inside...",
-  "status.cannot_reblog": "This post cannot be boosted",
+  "standalone.public_title": "Dê uma espiada...",
+  "status.cannot_reblog": "Esta postagem não pode ser compartilhada",
   "status.delete": "Eliminar",
+  "status.embed": "Incorporar",
   "status.favourite": "Adicionar aos favoritos",
   "status.load_more": "Carregar mais",
-  "status.media_hidden": "Media escondida",
+  "status.media_hidden": "Mídia escondida",
   "status.mention": "Mencionar @{name}",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "Silenciar conversa",
   "status.open": "Expandir",
-  "status.reblog": "Partilhar",
-  "status.reblogged_by": "{name} partilhou",
+  "status.pin": "Fixar no perfil",
+  "status.reblog": "Compartilhar",
+  "status.reblogged_by": "{name} compartilhou",
   "status.reply": "Responder",
-  "status.replyAll": "Reply to thread",
-  "status.report": "Denúnciar @{name}",
+  "status.replyAll": "Responder à sequência",
+  "status.report": "Denunciar @{name}",
   "status.sensitive_toggle": "Clique para ver",
   "status.sensitive_warning": "Conteúdo sensível",
-  "status.share": "Share",
+  "status.share": "Compartilhar",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
-  "status.unmute_conversation": "Unmute conversation",
+  "status.unmute_conversation": "Desativar silêncio desta conversa",
+  "status.unpin": "Desafixar do perfil",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
-  "tabs_bar.home": "Home",
+  "tabs_bar.home": "Página inicial",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificações",
   "upload_area.title": "Arraste e solte para enviar",
-  "upload_button.label": "Adicionar media",
+  "upload_button.label": "Adicionar mídia",
   "upload_form.undo": "Anular",
-  "upload_progress.label": "A gravar...",
+  "upload_progress.label": "Salvando...",
+  "video.close": "Fechar vídeo",
+  "video.exit_fullscreen": "Sair da tela cheia",
+  "video.expand": "Expandir vídeo",
+  "video.fullscreen": "Tela cheia",
+  "video.hide": "Esconder vídeo",
+  "video.mute": "Silenciar vídeo",
+  "video.pause": "Parar",
+  "video.play": "Reproduzir",
+  "video.unmute": "Retirar silêncio",
   "video_player.expand": "Expandir vídeo",
   "video_player.toggle_sound": "Ligar/Desligar som",
   "video_player.toggle_visible": "Ligar/Desligar vídeo",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 55d2f05de..782aaf114 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -33,6 +33,7 @@
   "column.home": "Home",
   "column.mutes": "Utilizadores silenciados",
   "column.notifications": "Notificações",
+  "column.pins": "Pinned toot",
   "column.public": "Global",
   "column_back_button.label": "Voltar",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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": "Em que estás a pensar?",
-  "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.",
   "compose_form.publish": "Publicar",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Marcar media como conteúdo sensível",
@@ -63,14 +63,20 @@
   "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": "Inserir 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": "Ainda não existem conteúdo local para mostrar!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Mais informações",
   "navigation_bar.logout": "Sair",
   "navigation_bar.mutes": "Utilizadores silenciados",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Preferências",
   "navigation_bar.public_timeline": "Global",
   "notification.favourite": "{name} adicionou o teu post aos favoritos",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Eliminar",
+  "status.embed": "Embed",
   "status.favourite": "Adicionar aos favoritos",
   "status.load_more": "Carregar mais",
   "status.media_hidden": "Media escondida",
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir",
+  "status.pin": "Pin on profile",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
   "status.reply": "Responder",
@@ -179,6 +188,7 @@
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
   "tabs_bar.home": "Home",
@@ -188,6 +198,15 @@
   "upload_button.label": "Adicionar media",
   "upload_form.undo": "Anular",
   "upload_progress.label": "A gravar...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Expandir vídeo",
   "video_player.toggle_sound": "Ligar/Desligar som",
   "video_player.toggle_visible": "Ligar/Desligar vídeo",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 1abfb4370..6f39d098c 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -1,7 +1,7 @@
 {
   "account.block": "Блокировать",
   "account.block_domain": "Блокировать все с {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.disclaimer_full": "Нижеуказанная информация может не полностью отражать профиль пользователя.",
   "account.edit_profile": "Изменить профиль",
   "account.follow": "Подписаться",
   "account.followers": "Подписаны",
@@ -13,19 +13,19 @@
   "account.posts": "Посты",
   "account.report": "Пожаловаться",
   "account.requested": "Ожидает подтверждения",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "Поделиться профилем @{name}",
   "account.unblock": "Разблокировать",
   "account.unblock_domain": "Разблокировать {domain}",
   "account.unfollow": "Отписаться",
   "account.unmute": "Снять глушение",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "Показать полный профиль",
   "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "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_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
+  "bundle_column_error.retry": "Попробовать снова",
+  "bundle_column_error.title": "Ошибка сети",
+  "bundle_modal_error.close": "Закрыть",
+  "bundle_modal_error.message": "Что-то пошло не так при загрузке этого компонента.",
+  "bundle_modal_error.retry": "Попробовать снова",
   "column.blocks": "Список блокировки",
   "column.community": "Локальная лента",
   "column.favourites": "Понравившееся",
@@ -33,20 +33,20 @@
   "column.home": "Главная",
   "column.mutes": "Список глушения",
   "column.notifications": "Уведомления",
+  "column.pins": "Pinned toot",
   "column.public": "Глобальная лента",
   "column_back_button.label": "Назад",
-  "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.hide_settings": "Скрыть настройки",
+  "column_header.moveLeft_settings": "Передвинуть колонку влево",
+  "column_header.moveRight_settings": "Передвинуть колонку вправо",
   "column_header.pin": "Закрепить",
-  "column_header.show_settings": "Show settings",
+  "column_header.show_settings": "Показать настройки",
   "column_header.unpin": "Открепить",
   "column_subheading.navigation": "Навигация",
   "column_subheading.settings": "Настройки",
   "compose_form.lock_disclaimer": "Ваш аккаунт не {locked}. Любой человек может подписаться на Вас и просматривать посты для подписчиков.",
   "compose_form.lock_disclaimer.lock": "закрыт",
   "compose_form.placeholder": "О чем Вы думаете?",
-  "compose_form.privacy_disclaimer": "Ваш приватный статус будет доставлен упомянутым пользователям на доменах {domains}. Доверяете ли вы {domainsCount, plural, one {этому серверу} other {этим серверам}}? Приватность постов работает только на узлах Mastodon. Если {domains} {domainsCount, plural, one {не является узлом Mastodon} other {не являются узлами Mastodon}}, приватность поста не будет указана, и он может оказаться продвинут или иным образом показан не обозначенным Вами пользователям.",
   "compose_form.publish": "Трубить",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Отметить как чувствительный контент",
@@ -61,16 +61,22 @@
   "confirmations.domain_block.message": "Вы на самом деле уверены, что хотите блокировать весь {domain}? В большинстве случаев нескольких отдельных блокировок или глушений достаточно.",
   "confirmations.mute.confirm": "Заглушить",
   "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unfollow.confirm": "Отписаться",
+  "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {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": "Занятия",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Флаги",
   "emoji_button.food": "Еда и напитки",
   "emoji_button.label": "Вставить эмодзи",
   "emoji_button.nature": "Природа",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Предметы",
   "emoji_button.people": "Люди",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Найти...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Символы",
   "emoji_button.travel": "Путешествия",
   "empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!",
@@ -94,8 +100,8 @@
   "home.column_settings.show_replies": "Показывать ответы",
   "home.settings": "Настройки колонки",
   "lightbox.close": "Закрыть",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "Далее",
+  "lightbox.previous": "Назад",
   "loading_indicator.label": "Загрузка...",
   "media_gallery.toggle_visible": "Показать/скрыть",
   "missing_indicator.label": "Не найдено",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Об узле",
   "navigation_bar.logout": "Выйти",
   "navigation_bar.mutes": "Список глушения",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Опции",
   "navigation_bar.public_timeline": "Глобальная лента",
   "notification.favourite": "{name} понравился Ваш статус",
@@ -119,8 +126,8 @@
   "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.push": "Push-уведомления",
+  "notifications.column_settings.push_meta": "Это устройство",
   "notifications.column_settings.reblog": "Продвижения:",
   "notifications.column_settings.show": "Показывать в колонке",
   "notifications.column_settings.sound": "Проигрывать звук",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
   "status.delete": "Удалить",
+  "status.embed": "Embed",
   "status.favourite": "Нравится",
   "status.load_more": "Показать еще",
   "status.media_hidden": "Медиаконтент скрыт",
   "status.mention": "Упомянуть @{name}",
   "status.mute_conversation": "Заглушить тред",
   "status.open": "Развернуть статус",
+  "status.pin": "Pin on profile",
   "status.reblog": "Продвинуть",
   "status.reblogged_by": "{name} продвинул(а)",
   "status.reply": "Ответить",
@@ -179,6 +188,7 @@
   "status.show_less": "Свернуть",
   "status.show_more": "Развернуть",
   "status.unmute_conversation": "Снять глушение с треда",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Написать",
   "tabs_bar.federated_timeline": "Глобальная",
   "tabs_bar.home": "Главная",
@@ -188,6 +198,15 @@
   "upload_button.label": "Добавить медиаконтент",
   "upload_form.undo": "Отменить",
   "upload_progress.label": "Загрузка...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Развернуть видео",
   "video_player.toggle_sound": "Вкл./выкл. звук",
   "video_player.toggle_visible": "Показать/скрыть",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index aa0929f82..ecc7a00db 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -33,6 +33,7 @@
   "column.home": "Home",
   "column.mutes": "Muted users",
   "column.notifications": "Notifications",
+  "column.pins": "Pinned toot",
   "column.public": "Federated timeline",
   "column_back_button.label": "Back",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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?",
-  "compose_form.privacy_disclaimer": "Your post will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is not a public post, and it may be boosted or otherwise made visible to unintended recipients.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Mark media as sensitive",
@@ -63,14 +63,20 @@
   "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!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "About this instance",
   "navigation_bar.logout": "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",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
+  "status.embed": "Embed",
   "status.favourite": "Favourite",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boosted",
   "status.reply": "Reply",
@@ -179,6 +188,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
@@ -188,6 +198,15 @@
   "upload_button.label": "Add media",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Expand video",
   "video_player.toggle_sound": "Toggle sound",
   "video_player.toggle_visible": "Toggle visibility",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 37ce8597e..b7ecd2cdb 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -33,6 +33,7 @@
   "column.home": "Anasayfa",
   "column.mutes": "Susturulmuş kullanıcılar",
   "column.notifications": "Bildirimler",
+  "column.pins": "Pinned toot",
   "column.public": "Federe zaman tüneli",
   "column_back_button.label": "Geri",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "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",
   "compose_form.placeholder": "Ne düşünüyorsun?",
-  "compose_form.privacy_disclaimer": "Gönderiniz {domains}’teki bahsettiğiniz kullanıcılara iletilecektir.{domainsCount, plural, one {bu sunucuya} other {bu sunuculara}} güveniyor musunuz? Gönderi gizliliği sadece Mastodon sunucularında çalışır. Eğer {domains} {domainsCount, plural, one {bir Mastodon sunucusu değilse} other {Mastodon sunucuları değilse}}, gönderinizin herkese açık bir gönderi olmadığına ilişkin bir gösterge bulunmayacaktır. Bu yüzden gönderiniz boost edilebilir veya istenmeyen alıcılara görünebilir.",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Görseli hassas olarak işaretle",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "{name} kullanıcısını sessize almak istiyor musunuz?",
   "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": "Aktivite",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Bayraklar",
   "emoji_button.food": "Yiyecek ve İçecek",
   "emoji_button.label": "Emoji ekle",
   "emoji_button.nature": "Doğa",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Nesneler",
   "emoji_button.people": "İnsanlar",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Emoji ara...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Semboller",
   "emoji_button.travel": "Seyahat ve Yerler",
   "empty_column.community": "Yerel zaman tüneliniz boş. Daha fazla eğlence için herkese açık bir gönderi paylaşın.",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Genişletilmiş bilgi",
   "navigation_bar.logout": "Çıkış",
   "navigation_bar.mutes": "Sessize alınmış kullanıcılar",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Tercihler",
   "navigation_bar.public_timeline": "Federe zaman tüneli",
   "notification.favourite": "{name} senin durumunu favorilere ekledi",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Bu gönderi boost edilemez",
   "status.delete": "Sil",
+  "status.embed": "Embed",
   "status.favourite": "Favorilere ekle",
   "status.load_more": "Daha fazla",
   "status.media_hidden": "Gizli görsel",
   "status.mention": "Bahset @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Bu gönderiyi genişlet",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost'la",
   "status.reblogged_by": "{name} boost etti",
   "status.reply": "Cevapla",
@@ -179,6 +188,7 @@
   "status.show_less": "Daha azı",
   "status.show_more": "Daha fazlası",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Oluştur",
   "tabs_bar.federated_timeline": "Federe",
   "tabs_bar.home": "Ana sayfa",
@@ -188,6 +198,15 @@
   "upload_button.label": "Görsel ekle",
   "upload_form.undo": "Geri al",
   "upload_progress.label": "Yükleniyor...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Videoyu genişlet",
   "video_player.toggle_sound": "Sesi aç/kapa",
   "video_player.toggle_visible": "Göster/gizle",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index fea7bd94e..45b2c2ee0 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -33,6 +33,7 @@
   "column.home": "Головна",
   "column.mutes": "Заглушені користувачі",
   "column.notifications": "Сповіщення",
+  "column.pins": "Pinned toot",
   "column.public": "Глобальна стрічка",
   "column_back_button.label": "Назад",
   "column_header.hide_settings": "Hide settings",
@@ -46,7 +47,6 @@
   "compose_form.lock_disclaimer": "Ваш акаунт не {locked}. Кожен може підписатися на Вас та бачити Ваші приватні пости.",
   "compose_form.lock_disclaimer.lock": "приватний",
   "compose_form.placeholder": "Що у Вас на думці?",
-  "compose_form.privacy_disclaimer": "Ваш приватний допис буде доставлено до згаданих користувачів на доменах {domains}. Ви довіряєте {domainsCount, plural, one {цьому серверу} other {цим серверам}}? Приватність постів працює тільки на інстанціях Mastodon. Якщо {domains} {domainsCount, plural, one {не є інстанцією Mastodon} other {не є інстанціями Mastodon}}, приватність поста не буде активована, та він може бути передмухнутий або іншим чином показаний не позначеним Вами користувачам.",
   "compose_form.publish": "Дмухнути",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Відмітити як непристойний зміст",
@@ -63,14 +63,20 @@
   "confirmations.mute.message": "Ви впевнені, що хочете заглушити {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": "Заняття",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "Прапори",
   "emoji_button.food": "Їжа та напої",
   "emoji_button.label": "Вставити емодзі",
   "emoji_button.nature": "Природа",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Предмети",
   "emoji_button.people": "Люди",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "Знайти...",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Символи",
   "emoji_button.travel": "Подорожі",
   "empty_column.community": "Локальна стрічка пуста. Напишіть щось, щоб розігріти народ!",
@@ -107,6 +113,7 @@
   "navigation_bar.info": "Про інстанцію",
   "navigation_bar.logout": "Вийти",
   "navigation_bar.mutes": "Заглушені користувачі",
+  "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Налаштування",
   "navigation_bar.public_timeline": "Глобальна стрічка",
   "notification.favourite": "{name} сподобався ваш допис",
@@ -162,12 +169,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Цей допис не може бути передмухнутий",
   "status.delete": "Видалити",
+  "status.embed": "Embed",
   "status.favourite": "Подобається",
   "status.load_more": "Завантажити більше",
   "status.media_hidden": "Медіаконтент приховано",
   "status.mention": "Згадати",
   "status.mute_conversation": "Заглушити діалог",
   "status.open": "Розгорнути допис",
+  "status.pin": "Pin on profile",
   "status.reblog": "Передмухнути",
   "status.reblogged_by": "{name} передмухнув(-ла)",
   "status.reply": "Відповісти",
@@ -179,6 +188,7 @@
   "status.show_less": "Згорнути",
   "status.show_more": "Розгорнути",
   "status.unmute_conversation": "Зняти глушення з діалогу",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Написати",
   "tabs_bar.federated_timeline": "Глобальна",
   "tabs_bar.home": "Головна",
@@ -188,6 +198,15 @@
   "upload_button.label": "Додати медіаконтент",
   "upload_form.undo": "Відмінити",
   "upload_progress.label": "Завантаження...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound",
   "video_player.expand": "Розгорнути ",
   "video_player.toggle_sound": "Увімкнути/вимкнути звук",
   "video_player.toggle_visible": "Показати/приховати",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index d0c4b3d1b..58e3d6780 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -1,54 +1,54 @@
 {
   "account.block": "屏蔽 @{name}",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "隐藏一切来自 {domain} 的嘟文",
+  "account.disclaimer_full": "下列资料不一定完整。",
   "account.edit_profile": "修改个人资料",
   "account.follow": "关注",
   "account.followers": "关注者",
   "account.follows": "正关注",
   "account.follows_you": "关注你",
-  "account.media": "Media",
+  "account.media": "媒体",
   "account.mention": "提及 @{name}",
   "account.mute": "将 @{name} 静音",
   "account.posts": "嘟文",
   "account.report": "举报 @{name}",
   "account.requested": "等待审批",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "分享 @{name}的个人资料",
   "account.unblock": "解除对 @{name} 的屏蔽",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "不再隐藏 {domain}",
   "account.unfollow": "取消关注",
   "account.unmute": "取消 @{name} 的静音",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "查看完整资料",
   "boost_modal.combo": "如你想在下次路过时显示,请按{combo},",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "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_column_error.body": "载入组件出错。",
+  "bundle_column_error.retry": "重试",
+  "bundle_column_error.title": "网络错误",
+  "bundle_modal_error.close": "关闭",
+  "bundle_modal_error.message": "载入组件出错。",
+  "bundle_modal_error.retry": "重试",
   "column.blocks": "屏蔽用户",
   "column.community": "本站时间轴",
-  "column.favourites": "赞过的嘟文",
+  "column.favourites": "收藏过的嘟文",
   "column.follow_requests": "关注请求",
   "column.home": "主页",
   "column.mutes": "被静音的用户",
   "column.notifications": "通知",
+  "column.pins": "置顶嘟文",
   "column.public": "跨站公共时间轴",
-  "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_back_button.label": "返回",
+  "column_header.hide_settings": "隐藏设置",
+  "column_header.moveLeft_settings": "将栏左移",
+  "column_header.moveRight_settings": "将栏右移",
+  "column_header.pin": "固定",
+  "column_header.show_settings": "显示设置",
+  "column_header.unpin": "取下",
   "column_subheading.navigation": "导航",
   "column_subheading.settings": "设置",
-  "compose_form.lock_disclaimer": "你的账户没 {locked}. 任何人可以通过关注你来查看只有关注者可见的嘟文.",
+  "compose_form.lock_disclaimer": "你的帐户没 {locked}. 任何人可以通过关注你来查看只有关注者可见的嘟文.",
   "compose_form.lock_disclaimer.lock": "被保护",
   "compose_form.placeholder": "在想啥?",
-  "compose_form.privacy_disclaimer": "你的私人嘟文,将被发送至你所提及的 {domains} 用户。你是否信任{domainsCount, plural, one {这个网站} other {这些网站}}?请留意,嘟文隐私设置只适用于各 Mastodon 服务器实例,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服务器实例} other {之中有些不是 Mastodon 服务器实例}},对方将无法收到这篇嘟文的隐私设置,然后可能被转嘟给不能预知的用户阅读。",
   "compose_form.publish": "嘟嘟",
-  "compose_form.publish_loud": "{publish}!",
+  "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "将媒体文件标示为“敏感内容”",
   "compose_form.spoiler": "将部分文本藏于警告消息之后",
   "compose_form.spoiler_placeholder": "敏感内容的警告消息",
@@ -57,26 +57,32 @@
   "confirmations.block.message": "想好了,真的要屏蔽 {name}?",
   "confirmations.delete.confirm": "删除",
   "confirmations.delete.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.domain_block.confirm": "隐藏整个网站",
+  "confirmations.domain_block.message": "你真的真的确定要隐藏整个 {domain} ?多数情况下,封锁或静音几个特定目标就好。",
   "confirmations.mute.confirm": "静音",
   "confirmations.mute.message": "想好了,真的要静音 {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unfollow.confirm": "取消关注",
+  "confirmations.unfollow.message": "确定要取消关注 {name}吗?",
+  "embed.instructions": "要内嵌此嘟文,请将以下代码贴进你的网站。",
+  "embed.preview": "到时大概长这样:",
   "emoji_button.activity": "活动",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "旗帜",
   "emoji_button.food": "食物和饮料",
   "emoji_button.label": "加入表情符号",
   "emoji_button.nature": "自然",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "物体",
   "emoji_button.people": "人物",
-  "emoji_button.search": "搜索...",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "搜索…",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "符号",
   "emoji_button.travel": "旅途和地点",
-  "empty_column.community": "本站时间轴暂时未有内容,快贴文来抢头香啊!",
+  "empty_column.community": "本站时间轴暂时未有内容,快嘟几个来抢头香啊!",
   "empty_column.hashtag": "这个标签暂时未有内容。",
   "empty_column.home": "你还没有关注任何用户。快看看{public},向其他用户搭讪吧。",
-  "empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
+  "empty_column.home.inactivity": "你的主页暂时没有内容。也许你太久没有来了?如果是这样,文章会慢慢出来,请稍后再看。",
   "empty_column.home.public_timeline": "公共时间轴",
   "empty_column.notifications": "你没有任何通知纪录,快向其他用户搭讪吧。",
   "empty_column.public": "跨站公共时间轴暂时没有内容!快写一些公共的嘟文,或者关注另一些服务器实例的用户吧!你和本站、友站的交流,将决定这里出现的内容。",
@@ -86,7 +92,7 @@
   "getting_started.faq": "FAQ",
   "getting_started.heading": "开始使用",
   "getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。",
-  "getting_started.userguide": "User Guide",
+  "getting_started.userguide": "用户指南",
   "home.column_settings.advanced": "高端",
   "home.column_settings.basic": "基本",
   "home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤",
@@ -94,33 +100,34 @@
   "home.column_settings.show_replies": "显示回应嘟文",
   "home.settings": "字段设置",
   "lightbox.close": "关闭",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "下一步",
+  "lightbox.previous": "上一步",
   "loading_indicator.label": "加载中……",
   "media_gallery.toggle_visible": "打开或关上",
   "missing_indicator.label": "找不到内容",
   "navigation_bar.blocks": "被屏蔽的用户",
   "navigation_bar.community_timeline": "本站时间轴",
   "navigation_bar.edit_profile": "修改个人资料",
-  "navigation_bar.favourites": "赞的内容",
+  "navigation_bar.favourites": "收藏的内容",
   "navigation_bar.follow_requests": "关注请求",
   "navigation_bar.info": "关于本站",
   "navigation_bar.logout": "注销",
   "navigation_bar.mutes": "被静音的用户",
+  "navigation_bar.pins": "置顶嘟文",
   "navigation_bar.preferences": "首选项",
   "navigation_bar.public_timeline": "跨站公共时间轴",
-  "notification.favourite": "{name} 赞了你的嘟文",
+  "notification.favourite": "{name} 收藏了你的嘟文",
   "notification.follow": "{name} 开始关注你",
   "notification.mention": "{name} 提及你",
   "notification.reblog": "{name} 转嘟了你的嘟文",
   "notifications.clear": "清空通知纪录",
   "notifications.clear_confirmation": "你确定要清空通知纪录吗?",
   "notifications.column_settings.alert": "显示桌面通知",
-  "notifications.column_settings.favourite": "你的嘟文被赞:",
+  "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.push": "推送通知",
+  "notifications.column_settings.push_meta": "此设备",
   "notifications.column_settings.reblog": "你的嘟文被转嘟:",
   "notifications.column_settings.show": "在通知栏显示",
   "notifications.column_settings.sound": "播放音效",
@@ -130,18 +137,18 @@
   "onboarding.page_four.home": "你的主时间轴上是你关注的用户的嘟文.",
   "onboarding.page_four.notifications": "如果你和他人产生了互动,便会出现在通知列上啦~",
   "onboarding.page_one.federation": "Mastodon是由一系列独立的服务器共同打造的强大的社交网络,我们将这些独立但又相互连接的服务器叫做服务器实例。",
-  "onboarding.page_one.handle": "你在 {domain}, {handle} 就是你的完整账户名称。",
+  "onboarding.page_one.handle": "你在 {domain}, {handle} 就是你的完整帐户名称。",
   "onboarding.page_one.welcome": "欢迎来到 Mastodon!",
   "onboarding.page_six.admin": "{admin} 是你所在服务器实例的管理员.",
-  "onboarding.page_six.almost_done": "快完成了...",
+  "onboarding.page_six.almost_done": "差不多了…",
   "onboarding.page_six.appetoot": "嗷呜~",
   "onboarding.page_six.apps_available": "也有适用于 iOS, Android 和其它平台的 {apps} 咯~",
   "onboarding.page_six.github": "Mastodon 是自由的开放源代码软件。欢迎来 {github} 报告问题,提交功能请求,或者贡献代码 :-)",
   "onboarding.page_six.guidelines": "社区指南",
-  "onboarding.page_six.read_guidelines": "别忘了看看 {domain} 的 {guidelines}!",
+  "onboarding.page_six.read_guidelines": "别忘了看看 {domain} 的 {guidelines}!",
   "onboarding.page_six.various_app": "移动应用程序",
   "onboarding.page_three.profile": "修改你的个人资料,比如头像、简介、和昵称等等。在那还可以找到其它首选项。",
-  "onboarding.page_three.search": "用搜索来找人和标签吧,比如 {illustration} 或者 {introductions}。想找其它服务器实例上的人,用完整账户名称(用户名@域名)啦。",
+  "onboarding.page_three.search": "用搜索来找人和标签吧,比如 {illustration} 或者 {introductions}。想找其它服务器实例上的人,用完整帐户名称(用户名@域名)啦。",
   "onboarding.page_two.compose": "从这里开始嘟!上面的按钮提供了上传图片,修改隐私设置和提示敏感内容等多种功能。.",
   "onboarding.skip": "好啦好啦我知道啦",
   "privacy.change": "调整隐私设置",
@@ -159,26 +166,29 @@
   "report.target": "Reporting",
   "search.placeholder": "搜索",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "大家都在干啥?",
   "status.cannot_reblog": "没法转嘟这条嘟文啦……",
   "status.delete": "删除",
-  "status.favourite": "赞",
+  "status.embed": "嵌入",
+  "status.favourite": "收藏",
   "status.load_more": "加载更多",
   "status.media_hidden": "隐藏媒体内容",
   "status.mention": "提及 @{name}",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "静音对话",
   "status.open": "展开嘟文",
+  "status.pin": "置顶到资料",
   "status.reblog": "转嘟",
   "status.reblogged_by": "{name} 转嘟",
   "status.reply": "回应",
-  "status.replyAll": "Reply to thread",
+  "status.replyAll": "回应整串",
   "status.report": "举报 @{name}",
   "status.sensitive_toggle": "点击显示",
   "status.sensitive_warning": "敏感内容",
   "status.share": "Share",
   "status.show_less": "减少显示",
   "status.show_more": "显示更多",
-  "status.unmute_conversation": "Unmute conversation",
+  "status.unmute_conversation": "解禁对话",
+  "status.unpin": "解除置顶",
   "tabs_bar.compose": "撰写",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主页",
@@ -188,6 +198,15 @@
   "upload_button.label": "上传媒体文件",
   "upload_form.undo": "还原",
   "upload_progress.label": "上传中……",
+  "video.close": "关闭影片",
+  "video.exit_fullscreen": "退出全屏",
+  "video.expand": "展开影片",
+  "video.fullscreen": "全屏",
+  "video.hide": "隐藏影片",
+  "video.mute": "静音",
+  "video.pause": "暂停",
+  "video.play": "播放",
+  "video.unmute": "解除静音",
   "video_player.expand": "展开影片",
   "video_player.toggle_sound": "开关音效",
   "video_player.toggle_visible": "打开或关上",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 7312aae82..610aa6daf 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -1,54 +1,54 @@
 {
   "account.block": "封鎖 @{name}",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "隱藏來自 {domain} 的一切文章",
+  "account.disclaimer_full": "下列資料不一定完整。",
   "account.edit_profile": "修改個人資料",
   "account.follow": "關注",
   "account.followers": "關注的人",
-  "account.follows": "正在關注",
+  "account.follows": "正關注",
   "account.follows_you": "關注你",
-  "account.media": "Media",
+  "account.media": "媒體",
   "account.mention": "提及 @{name}",
   "account.mute": "將 @{name} 靜音",
   "account.posts": "文章",
   "account.report": "舉報 @{name}",
   "account.requested": "等候審批",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "分享 @{name} 的個人資料",
   "account.unblock": "解除對 @{name} 的封鎖",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "不再隱藏 {domain}",
   "account.unfollow": "取消關注",
   "account.unmute": "取消 @{name} 的靜音",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "查看完整資料",
   "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "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_column_error.body": "加載本組件出錯。",
+  "bundle_column_error.retry": "重試",
+  "bundle_column_error.title": "網絡錯誤",
+  "bundle_modal_error.close": "關閉",
+  "bundle_modal_error.message": "加載本組件出錯。",
+  "bundle_modal_error.retry": "重試",
   "column.blocks": "封鎖用戶",
   "column.community": "本站時間軸",
-  "column.favourites": "喜歡的文章",
+  "column.favourites": "最愛的文章",
   "column.follow_requests": "關注請求",
   "column.home": "主頁",
   "column.mutes": "靜音名單",
   "column.notifications": "通知",
+  "column.pins": "置頂文章",
   "column.public": "跨站時間軸",
   "column_back_button.label": "返回",
-  "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": "隱藏設定",
+  "column_header.moveLeft_settings": "將欄左移",
+  "column_header.moveRight_settings": "將欄右移",
+  "column_header.pin": "固定",
+  "column_header.show_settings": "顯示設定",
+  "column_header.unpin": "取下",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
   "compose_form.lock_disclaimer": "你的用戶狀態為「{locked}」,任何人都能立即關注你,然後看到「只有關注者能看」的文章。",
   "compose_form.lock_disclaimer.lock": "公共",
   "compose_form.placeholder": "你在想甚麼?",
-  "compose_form.privacy_disclaimer": "你的私人文章,將被遞送至 {domains}。你是否信任{domainsCount, plural, one {這個網站} other {這些網站}}?請留意,文章私隱設定只適用於 Mastodon 服務站,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服務站} other {之中有些不是 Mastodon 服務站}},對方將可無視文章的私隱設定,轉推文章給其他用戶閱讀。",
   "compose_form.publish": "發文",
-  "compose_form.publish_loud": "{publish}!",
+  "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "將媒體檔案標示為「敏感內容」",
   "compose_form.spoiler": "將部份文字藏於警告訊息之後",
   "compose_form.spoiler_placeholder": "敏感警告訊息",
@@ -57,23 +57,29 @@
   "confirmations.block.message": "你確定要封鎖{name}嗎?",
   "confirmations.delete.confirm": "刪除",
   "confirmations.delete.message": "你確定要刪除{name}嗎?",
-  "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.domain_block.confirm": "隱藏整個網站",
+  "confirmations.domain_block.message": "你真的真的確定要隱藏整個 {domain} ?多數情況下,比較推薦封鎖或靜音幾個特定目標就好。",
   "confirmations.mute.confirm": "靜音",
   "confirmations.mute.message": "你確定要將{name}靜音嗎?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unfollow.confirm": "取消關注",
+  "confirmations.unfollow.message": "真的不要繼續關注 {name} 了嗎?",
+  "embed.instructions": "要內嵌此文章,請將以下代碼貼進你的網站。",
+  "embed.preview": "看上去會是這樣:",
   "emoji_button.activity": "活動",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "旗幟",
   "emoji_button.food": "飲飲食食",
   "emoji_button.label": "加入表情符號",
   "emoji_button.nature": "自然",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "物品",
   "emoji_button.people": "人物",
+  "emoji_button.recent": "Frequently used",
   "emoji_button.search": "搜尋…",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "符號",
   "emoji_button.travel": "旅遊景物",
-  "empty_column.community": "本站時間軸暫時未有內容,快貼文來搶頭香啊!",
+  "empty_column.community": "本站時間軸暫時未有內容,快文章來搶頭香啊!",
   "empty_column.hashtag": "這個標籤暫時未有內容。",
   "empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。",
   "empty_column.home.inactivity": "你的主頁暫時沒有內容。也許你太久沒有來?如果是這樣,文章會慢慢出來,請稍後再看。",
@@ -94,34 +100,35 @@
   "home.column_settings.show_replies": "顯示回應文章",
   "home.settings": "欄位設定",
   "lightbox.close": "關閉",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "繼續",
+  "lightbox.previous": "回退",
   "loading_indicator.label": "載入中...",
   "media_gallery.toggle_visible": "打開或關上",
   "missing_indicator.label": "找不到內容",
   "navigation_bar.blocks": "被你封鎖的用戶",
   "navigation_bar.community_timeline": "本站時間軸",
   "navigation_bar.edit_profile": "修改個人資料",
-  "navigation_bar.favourites": "喜歡的內容",
+  "navigation_bar.favourites": "最愛的內容",
   "navigation_bar.follow_requests": "關注請求",
   "navigation_bar.info": "關於本服務站",
   "navigation_bar.logout": "登出",
   "navigation_bar.mutes": "被你靜音的用戶",
+  "navigation_bar.pins": "置頂文章",
   "navigation_bar.preferences": "偏好設定",
   "navigation_bar.public_timeline": "跨站時間軸",
-  "notification.favourite": "{name} 喜歡你的文章",
+  "notification.favourite": "{name} 收藏了你的文章",
   "notification.follow": "{name} 開始關注你",
   "notification.mention": "{name} 提及你",
   "notification.reblog": "{name} 轉推你的文章",
   "notifications.clear": "清空通知紀錄",
   "notifications.clear_confirmation": "你確定要清空通知紀錄嗎?",
   "notifications.column_settings.alert": "顯示桌面通知",
-  "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": "轉推你的文章:",
+  "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.show": "在通知欄顯示",
   "notifications.column_settings.sound": "播放音效",
   "onboarding.done": "開始使用",
@@ -159,15 +166,17 @@
   "report.target": "舉報",
   "search.placeholder": "搜尋",
   "search_results.total": "{count, number} 項結果",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "站點一瞥…",
   "status.cannot_reblog": "這篇文章無法被轉推",
   "status.delete": "刪除",
-  "status.favourite": "喜歡",
+  "status.embed": "鑲嵌",
+  "status.favourite": "收藏",
   "status.load_more": "載入更多",
   "status.media_hidden": "隱藏媒體內容",
   "status.mention": "提及 @{name}",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "靜音對話",
   "status.open": "展開文章",
+  "status.pin": "置頂到資料頁",
   "status.reblog": "轉推",
   "status.reblogged_by": "{name} 轉推",
   "status.reply": "回應",
@@ -178,7 +187,8 @@
   "status.share": "Share",
   "status.show_less": "減少顯示",
   "status.show_more": "顯示更多",
-  "status.unmute_conversation": "Unmute conversation",
+  "status.unmute_conversation": "解禁對話",
+  "status.unpin": "解除置頂",
   "tabs_bar.compose": "撰寫",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主頁",
@@ -188,6 +198,15 @@
   "upload_button.label": "上載媒體檔案",
   "upload_form.undo": "還原",
   "upload_progress.label": "上載中……",
+  "video.close": "關閉影片",
+  "video.exit_fullscreen": "退出全熒幕",
+  "video.expand": "展開影片",
+  "video.fullscreen": "全熒幕",
+  "video.hide": "隱藏影片",
+  "video.mute": "靜音",
+  "video.pause": "暫停",
+  "video.play": "播放",
+  "video.unmute": "解除靜音",
   "video_player.expand": "展開影片",
   "video_player.toggle_sound": "開關音效",
   "video_player.toggle_visible": "打開或關上",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 1c2e35272..ad2f1a05a 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -1,11 +1,11 @@
 {
   "account.block": "封鎖 @{name}",
-  "account.block_domain": "隱藏來自 {domain} 的一切",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
-  "account.edit_profile": "編輯用戶資訊",
+  "account.block_domain": "隱藏來自 {domain} 的一切貼文",
+  "account.disclaimer_full": "下列資料不一定完整。",
+  "account.edit_profile": "編輯用者資訊",
   "account.follow": "關注",
   "account.followers": "專注者",
-  "account.follows": "正在關注",
+  "account.follows": "正關注",
   "account.follows_you": "關注你",
   "account.media": "媒體",
   "account.mention": "提到 @{name}",
@@ -13,19 +13,19 @@
   "account.posts": "貼文",
   "account.report": "檢舉 @{name}",
   "account.requested": "正在等待許可",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "分享 @{name} 的用者資訊",
   "account.unblock": "取消封鎖 @{name}",
   "account.unblock_domain": "不再隱藏 {domain}",
   "account.unfollow": "取消關注",
   "account.unmute": "不再消音 @{name}",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "查看完整資訊",
   "boost_modal.combo": "下次你可以按 {combo} 來跳過",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "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_column_error.body": "加載本組件出錯。",
+  "bundle_column_error.retry": "重試",
+  "bundle_column_error.title": "網路錯誤",
+  "bundle_modal_error.close": "關閉",
+  "bundle_modal_error.message": "加載本組件出錯。",
+  "bundle_modal_error.retry": "重試",
   "column.blocks": "封鎖的使用者",
   "column.community": "本地時間軸",
   "column.favourites": "最愛",
@@ -33,21 +33,21 @@
   "column.home": "家",
   "column.mutes": "消音的使用者",
   "column.notifications": "通知",
+  "column.pins": "置頂貼文",
   "column.public": "聯盟時間軸",
   "column_back_button.label": "上一頁",
-  "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": "隱藏設定",
+  "column_header.moveLeft_settings": "將欄左移",
+  "column_header.moveRight_settings": "將欄右移",
+  "column_header.pin": "固定",
+  "column_header.show_settings": "顯示設定",
+  "column_header.unpin": "取下",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
   "compose_form.lock_disclaimer": "你的帳號沒有{locked}。任何人都可以關注你,看到發給關注者的貼文。",
   "compose_form.lock_disclaimer.lock": "上鎖",
   "compose_form.placeholder": "在想些什麼?",
-  "compose_form.privacy_disclaimer": "你的貼文會被傳到 {domains} 上被提到的使用者。你信任 {domainsCount, plural, one {這個伺服器} other {這些伺服器}}嗎?貼文的隱私設定只會在 Mastodon 副本上生效。如果 {domains} {domainsCount, plural, one {不是一個 Mastodon 副本} other {都不是 Mastodon 副本}},就不會被標記為非公開貼文,而且可能會被轉推或是讓不預期的人看見。",
-  "compose_form.publish": "推",
+  "compose_form.publish": "貼掉",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "將此媒體標為敏感",
   "compose_form.spoiler": "將訊息隱藏在警告訊息之後",
@@ -58,24 +58,30 @@
   "confirmations.delete.confirm": "刪除",
   "confirmations.delete.message": "你確定要刪除這個狀態?",
   "confirmations.domain_block.confirm": "隱藏整個網域",
-  "confirmations.domain_block.message": "你真的真的確定要封鎖整個 {domain} ?多數情況下,比較推薦封鎖或消音幾個特定目標就好。",
+  "confirmations.domain_block.message": "你真的真的確定要隱藏整個 {domain} ?多數情況下,比較推薦封鎖或消音幾個特定目標就好。",
   "confirmations.mute.confirm": "消音",
   "confirmations.mute.message": "你確定要消音 {name} ?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unfollow.confirm": "取消關注",
+  "confirmations.unfollow.message": "真的不要繼續關注 {name} 了嗎?",
+  "embed.instructions": "要內嵌此貼文,請將以下代碼貼進你的網站。",
+  "embed.preview": "看上去會變成這樣:",
   "emoji_button.activity": "活動",
+  "emoji_button.custom": "Custom",
   "emoji_button.flags": "旗幟",
   "emoji_button.food": "食物與飲料",
   "emoji_button.label": "插入表情符號",
   "emoji_button.nature": "自然",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "物件",
   "emoji_button.people": "人",
-  "emoji_button.search": "搜尋...",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "搜尋…",
+  "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "符號",
   "emoji_button.travel": "旅遊與地點",
   "empty_column.community": "本地時間軸是空的。公開寫點什麼吧!",
   "empty_column.hashtag": "這個主題標籤下什麼都沒有。",
-  "empty_column.home": "你還沒關注任何人。造訪{public}或利用搜尋功能找到其他用戶。",
+  "empty_column.home": "你還沒關注任何人。造訪{public}或利用搜尋功能找到其他用者。",
   "empty_column.home.inactivity": "你家的訊息摘要是空的。如果你很久沒活動了,很快它就會重新產生。",
   "empty_column.home.public_timeline": "公開時間軸",
   "empty_column.notifications": "還沒有任何通知。和別的使用者互動來開始對話。",
@@ -94,22 +100,23 @@
   "home.column_settings.show_replies": "顯示回應",
   "home.settings": "欄位設定",
   "lightbox.close": "關閉",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "繼續",
+  "lightbox.previous": "回退",
   "loading_indicator.label": "讀取中...",
   "media_gallery.toggle_visible": "切換可見性",
   "missing_indicator.label": "找不到",
   "navigation_bar.blocks": "封鎖的使用者",
   "navigation_bar.community_timeline": "本地時間軸",
-  "navigation_bar.edit_profile": "編輯用戶資訊",
+  "navigation_bar.edit_profile": "編輯用者資訊",
   "navigation_bar.favourites": "最愛",
   "navigation_bar.follow_requests": "關注請求",
   "navigation_bar.info": "關於本站",
   "navigation_bar.logout": "登出",
   "navigation_bar.mutes": "消音的使用者",
+  "navigation_bar.pins": "置頂貼文",
   "navigation_bar.preferences": "偏好設定",
   "navigation_bar.public_timeline": "聯盟時間軸",
-  "notification.favourite": "{name}喜歡你的狀態",
+  "notification.favourite": "{name}收藏了你的狀態",
   "notification.follow": "{name}關注了你",
   "notification.mention": "{name}提到了你",
   "notification.reblog": "{name}推了你的狀態",
@@ -119,8 +126,8 @@
   "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.push": "推送通知",
+  "notifications.column_settings.push_meta": "這臺設備",
   "notifications.column_settings.reblog": "轉推:",
   "notifications.column_settings.show": "顯示在欄位中",
   "notifications.column_settings.sound": "播放音效",
@@ -133,8 +140,8 @@
   "onboarding.page_one.handle": "你在 {domain} 上,所以你的帳號全名是 {handle}",
   "onboarding.page_one.welcome": "歡迎來到 Mastodon !",
   "onboarding.page_six.admin": "你的副本的管理員是 {admin} 。",
-  "onboarding.page_six.almost_done": "快好了...",
-  "onboarding.page_six.appetoot": "推口大開!",
+  "onboarding.page_six.almost_done": "快好了…",
+  "onboarding.page_six.appetoot": "貼口大開!",
   "onboarding.page_six.apps_available": "在 iOS 、 Android 和其他平台上有這些 {apps} 可以用。",
   "onboarding.page_six.github": "Mastodon 是自由的開源軟體。你可以在 {github} 上回報臭蟲、請求新功能或是做出貢獻。",
   "onboarding.page_six.guidelines": "社群指南",
@@ -159,15 +166,17 @@
   "report.target": "通報中",
   "search.placeholder": "搜尋",
   "search_results.total": "{count, number} 項結果",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "站點一瞥…",
   "status.cannot_reblog": "此貼文無法轉推",
   "status.delete": "刪除",
-  "status.favourite": "喜愛",
+  "status.embed": "Embed",
+  "status.favourite": "收藏",
   "status.load_more": "載入更多",
   "status.media_hidden": "媒體已隱藏",
   "status.mention": "提到 @{name}",
   "status.mute_conversation": "消音對話",
   "status.open": "展開這個狀態",
+  "status.pin": "置頂到個人資訊頁",
   "status.reblog": "轉推",
   "status.reblogged_by": "{name} 轉推了",
   "status.reply": "回應",
@@ -179,6 +188,7 @@
   "status.show_less": "看少點",
   "status.show_more": "看更多",
   "status.unmute_conversation": "不消音對話",
+  "status.unpin": "解除置頂",
   "tabs_bar.compose": "編輯",
   "tabs_bar.federated_timeline": "聯盟",
   "tabs_bar.home": "家",
@@ -188,6 +198,15 @@
   "upload_button.label": "增加媒體",
   "upload_form.undo": "復原",
   "upload_progress.label": "上傳中...",
+  "video.close": "關閉影片",
+  "video.exit_fullscreen": "退出全熒幕",
+  "video.expand": "展開影片",
+  "video.fullscreen": "全熒幕",
+  "video.hide": "隱藏影片",
+  "video.mute": "消音",
+  "video.pause": "暫停",
+  "video.play": "播放",
+  "video.unmute": "解除消音",
   "video_player.expand": "展開影片",
   "video_player.toggle_sound": "切換音效",
   "video_player.toggle_visible": "切換可見性",
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 4d7c3adc9..5391a93ae 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -44,7 +44,9 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { STORE_HYDRATE } from '../actions/store';
+import emojify from '../emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
 
 const normalizeAccount = (state, account) => {
   account = { ...account };
@@ -53,6 +55,10 @@ const normalizeAccount = (state, account) => {
   delete account.following_count;
   delete account.statuses_count;
 
+  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);
+
   return state.set(account.id, fromJS(account));
 };
 
@@ -104,7 +110,7 @@ export default function accounts(state = initialState, action) {
   case BLOCKS_EXPAND_SUCCESS:
   case MUTES_FETCH_SUCCESS:
   case MUTES_EXPAND_SUCCESS:
-    return normalizeAccounts(state, action.accounts);
+    return action.accounts ? normalizeAccounts(state, action.accounts) : state;
   case NOTIFICATIONS_REFRESH_SUCCESS:
   case NOTIFICATIONS_EXPAND_SUCCESS:
   case SEARCH_FETCH_SUCCESS:
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
index 4423e1b50..1ed0fe3e3 100644
--- a/app/javascript/mastodon/reducers/accounts_counters.js
+++ b/app/javascript/mastodon/reducers/accounts_counters.js
@@ -106,7 +106,7 @@ export default function accountsCounters(state = initialState, action) {
   case BLOCKS_EXPAND_SUCCESS:
   case MUTES_FETCH_SUCCESS:
   case MUTES_EXPAND_SUCCESS:
-    return normalizeAccounts(state, action.accounts);
+    return action.accounts ? normalizeAccounts(state, action.accounts) : state;
   case NOTIFICATIONS_REFRESH_SUCCESS:
   case NOTIFICATIONS_EXPAND_SUCCESS:
   case SEARCH_FETCH_SUCCESS:
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 07207c93b..5756a393f 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -128,7 +128,7 @@ const insertSuggestion = (state, position, token, completion) => {
 };
 
 const insertEmoji = (state, position, emojiData) => {
-  const emoji = emojiData.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join('');
+  const emoji = emojiData.native;
 
   return state.withMutations(map => {
     map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
@@ -149,10 +149,20 @@ const privacyPreference = (a, b) => {
   }
 };
 
+const hydrate = (state, hydratedState) => {
+  state = clearAll(state.merge(hydratedState));
+
+  if (hydratedState.has('text')) {
+    state = state.set('text', hydratedState.get('text'));
+  }
+
+  return state;
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    return clearAll(state.merge(action.state.get('compose')));
+    return hydrate(state, action.state.get('compose'));
   case COMPOSE_MOUNT:
     return state.set('mounted', true);
   case COMPOSE_UNMOUNT:
@@ -252,7 +262,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
-    return state.set('suggestions', ImmutableList(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
+    return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
   case COMPOSE_SUGGESTION_SELECT:
     return insertSuggestion(state, action.position, action.token, action.completion);
   case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js
new file mode 100644
index 000000000..d80c0d156
--- /dev/null
+++ b/app/javascript/mastodon/reducers/custom_emojis.js
@@ -0,0 +1,16 @@
+import { List as ImmutableList } from 'immutable';
+import { STORE_HYDRATE } from '../actions/store';
+import { emojiIndex } from 'emoji-mart';
+import { buildCustomEmojis } from '../emoji';
+
+const initialState = ImmutableList();
+
+export default function custom_emojis(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
+    return action.state.get('custom_emojis');
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/height_cache.js b/app/javascript/mastodon/reducers/height_cache.js
new file mode 100644
index 000000000..2f5716fae
--- /dev/null
+++ b/app/javascript/mastodon/reducers/height_cache.js
@@ -0,0 +1,23 @@
+import { Map as ImmutableMap } from 'immutable';
+import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from '../actions/height_cache';
+
+const initialState = ImmutableMap();
+
+const setHeight = (state, key, id, height) => {
+  return state.update(key, ImmutableMap(), map => map.set(id, height));
+};
+
+const clearHeights = () => {
+  return ImmutableMap();
+};
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case HEIGHT_CACHE_SET:
+    return setHeight(state, action.key, action.id, action.height);
+  case HEIGHT_CACHE_CLEAR:
+    return clearHeights();
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 86cda2adc..593d0efa4 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -14,12 +14,15 @@ import local_settings from '../../glitch/reducers/local_settings';
 import push_notifications from './push_notifications';
 import status_lists from './status_lists';
 import cards from './cards';
+import mutes from './mutes';
 import reports from './reports';
 import contexts from './contexts';
 import compose from './compose';
 import search from './search';
 import media_attachments from './media_attachments';
 import notifications from './notifications';
+import height_cache from './height_cache';
+import custom_emojis from './custom_emojis';
 
 const reducers = {
   timelines,
@@ -37,12 +40,15 @@ const reducers = {
   local_settings,
   push_notifications,
   cards,
+  mutes,
   reports,
   contexts,
   compose,
   search,
   media_attachments,
   notifications,
+  height_cache,
+  custom_emojis,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js
new file mode 100644
index 000000000..496e6846a
--- /dev/null
+++ b/app/javascript/mastodon/reducers/mutes.js
@@ -0,0 +1,29 @@
+import Immutable from 'immutable';
+
+import {
+  MUTES_INIT_MODAL,
+  MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+} from '../actions/mutes';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    isSubmitting: false,
+    account: null,
+    notifications: true,
+  }),
+});
+
+export default function mutes(state = initialState, action) {
+  switch (action.type) {
+  case MUTES_INIT_MODAL:
+    return state.withMutations((state) => {
+      state.setIn(['new', 'isSubmitting'], false);
+      state.setIn(['new', 'account'], action.account);
+      state.setIn(['new', 'notifications'], true);
+    });
+  case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
+    return state.setIn(['new', 'notifications'], !state.getIn(['new', 'notifications']));
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js
index 283c5b6f5..a08bbec38 100644
--- a/app/javascript/mastodon/reducers/reports.js
+++ b/app/javascript/mastodon/reducers/reports.js
@@ -28,7 +28,7 @@ export default function reports(state = initialState, action) {
       if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
         map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
         map.setIn(['new', 'comment'], '');
-      } else {
+      } else if (action.status) {
         map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
       }
     });
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
index bbc973302..c4aeb338f 100644
--- a/app/javascript/mastodon/reducers/status_lists.js
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -2,7 +2,16 @@ import {
   FAVOURITED_STATUSES_FETCH_SUCCESS,
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
+import {
+  PINNED_STATUSES_FETCH_SUCCESS,
+} from '../actions/pin_statuses';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
+} from '../actions/interactions';
 
 const initialState = ImmutableMap({
   favourites: ImmutableMap({
@@ -10,6 +19,11 @@ const initialState = ImmutableMap({
     loaded: false,
     items: ImmutableList(),
   }),
+  pins: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
 });
 
 const normalizeList = (state, listType, statuses, next) => {
@@ -27,12 +41,34 @@ const appendToList = (state, listType, statuses, next) => {
   }));
 };
 
+const prependOneToList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').unshift(status.get('id')));
+  }));
+};
+
+const removeOneFromList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').filter(item => item !== status.get('id')));
+  }));
+};
+
 export default function statusLists(state = initialState, action) {
   switch(action.type) {
   case FAVOURITED_STATUSES_FETCH_SUCCESS:
     return normalizeList(state, 'favourites', action.statuses, action.next);
   case FAVOURITED_STATUSES_EXPAND_SUCCESS:
     return appendToList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITE_SUCCESS:
+    return prependOneToList(state, 'favourites', action.status);
+  case UNFAVOURITE_SUCCESS:
+    return removeOneFromList(state, 'favourites', action.status);
+  case PINNED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'pins', action.statuses, action.next);
+  case PIN_SUCCESS:
+    return prependOneToList(state, 'pins', action.status);
+  case UNPIN_SUCCESS:
+    return removeOneFromList(state, 'pins', action.status);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index b1b1d0988..38b23504e 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -7,6 +7,8 @@ import {
   FAVOURITE_SUCCESS,
   FAVOURITE_FAIL,
   UNFAVOURITE_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
 } from '../actions/interactions';
 import {
   STATUS_FETCH_SUCCESS,
@@ -32,8 +34,15 @@ import {
   FAVOURITED_STATUSES_FETCH_SUCCESS,
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
+import {
+  PINNED_STATUSES_FETCH_SUCCESS,
+} from '../actions/pin_statuses';
 import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import emojify from '../emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const domParser = new DOMParser();
 
 const normalizeStatus = (state, status) => {
   if (!status) {
@@ -49,7 +58,14 @@ const normalizeStatus = (state, status) => {
   }
 
   const searchContent = [status.spoiler_text, status.content].join(' ').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
-  normalStatus.search_index = new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent;
+  const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
+    obj[`:${emoji.shortcode}:`] = emoji.url;
+    return obj;
+  }, {});
+
+  normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+  normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
+  normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
 
   return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
 };
@@ -94,6 +110,8 @@ export default function statuses(state = initialState, action) {
   case UNREBLOG_SUCCESS:
   case FAVOURITE_SUCCESS:
   case UNFAVOURITE_SUCCESS:
+  case PIN_SUCCESS:
+  case UNPIN_SUCCESS:
     return normalizeStatus(state, action.response);
   case FAVOURITE_REQUEST:
     return state.setIn([action.status.get('id'), 'favourited'], true);
@@ -114,6 +132,7 @@ export default function statuses(state = initialState, action) {
   case NOTIFICATIONS_EXPAND_SUCCESS:
   case FAVOURITED_STATUSES_FETCH_SUCCESS:
   case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case PINNED_STATUSES_FETCH_SUCCESS:
   case SEARCH_FETCH_SUCCESS:
     return normalizeStatuses(state, action.statuses);
   case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
index c089d37db..2af07e0fb 100644
--- a/app/javascript/mastodon/scroll.js
+++ b/app/javascript/mastodon/scroll.js
@@ -1,9 +1,9 @@
 const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
 
-const scrollTop = (node) => {
+const scroll = (node, key, target) => {
   const startTime = Date.now();
-  const offset    = node.scrollTop;
-  const targetY   = -offset;
+  const offset    = node[key];
+  const gap       = target - offset;
   const duration  = 1000;
   let interrupt   = false;
 
@@ -15,7 +15,7 @@ const scrollTop = (node) => {
       return;
     }
 
-    node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
+    node[key] = easingOutQuint(0, elapsed, offset, gap, duration);
     requestAnimationFrame(step);
   };
 
@@ -26,4 +26,5 @@ const scrollTop = (node) => {
   };
 };
 
-export default scrollTop;
+export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index acb85f626..f63cff335 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -31,8 +31,8 @@ const notify = options =>
       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 };
+      group.body  = `${options.title}\n${group.body}`;
+      group.data  = { ...group.data, count: group.data.count + 1 };
 
       return self.registration.showNotification(group.title, group);
     }
@@ -43,18 +43,18 @@ const notify = options =>
 const handlePush = (event) => {
   const options = event.data.json();
 
-  options.body = options.data.nsfw || options.data.content;
-  options.image = options.image || undefined; // Null results in a network request (404)
+  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.actions          = [expandAction];
+    options.hiddenActions    = options.data.actions.filter(action => action !== expandAction);
     options.data.hiddenImage = options.image;
-    options.image = undefined;
+    options.image            = undefined;
   } else {
     options.actions = options.data.actions;
   }
@@ -75,8 +75,8 @@ const cloneNotification = (notification) => {
 const expandNotification = (notification) => {
   const nextNotification = cloneNotification(notification);
 
-  nextNotification.body = notification.data.content;
-  nextNotification.image = notification.data.hiddenImage;
+  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);
@@ -105,8 +105,7 @@ const openUrl = url =>
       const webClients = clientList.filter(client => /\/web\//.test(client.url));
 
       if (webClients.length !== 0) {
-        const client = findBestClient(webClients);
-
+        const client       = findBestClient(webClients);
         const { pathname } = new URL(url);
 
         if (pathname.startsWith('/web/')) {
@@ -126,8 +125,7 @@ const openUrl = url =>
   });
 
 const removeActionFromNotification = (notification, action) => {
-  const actions = notification.actions.filter(act => act.action !== action.action);
-
+  const actions          = notification.actions.filter(act => act.action !== action.action);
   const nextNotification = cloneNotification(notification);
 
   nextNotification.actions = actions;
diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js
index 96ac63b52..3dbed09ea 100644
--- a/app/javascript/mastodon/web_push_subscription.js
+++ b/app/javascript/mastodon/web_push_subscription.js
@@ -48,7 +48,6 @@ export function register () {
 
   if (supportsPushNotifications) {
     if (!getApplicationServerKey()) {
-      // eslint-disable-next-line no-console
       console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
       return;
     }
@@ -84,10 +83,8 @@ export function register () {
       })
       .catch(error => {
         if (error.code === 20 && error.name === 'AbortError') {
-          // eslint-disable-next-line no-console
           console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
         } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
-          // eslint-disable-next-line no-console
           console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
         }
 
@@ -103,7 +100,6 @@ export function register () {
         }
       });
   } else {
-    // eslint-disable-next-line no-console
     console.warn('Your browser does not support Web Push Notifications.');
   }
 }
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index c06714dc1..aa94006c6 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -2,7 +2,7 @@ import loadPolyfills from '../mastodon/load_polyfills';
 
 // import default stylesheet with variables
 require('font-awesome/css/font-awesome.css');
-require('mastodon-application-style');
+import 'styles/application';
 
 require.context('../images/', true);
 
diff --git a/app/javascript/packs/frontends/mastodon.js b/app/javascript/packs/frontends/mastodon.js
deleted file mode 100644
index a983de36f..000000000
--- a/app/javascript/packs/frontends/mastodon.js
+++ /dev/null
@@ -1,16 +0,0 @@
-// This file replaces `app/javascript/packs/application.js` for use
-// with multiple frontends.
-
-import loadPolyfills from '../../mastodon/load_polyfills';
-
-// import default stylesheet with variables
-require('font-awesome/css/font-awesome.css');
-require('mastodon-application-style');
-
-require.context('../../images/', true);
-
-loadPolyfills().then(() => {
-  require('../../mastodon/main').default();
-}).catch(e => {
-  console.error(e);
-});
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index e9bb4a42e..8842d6dcb 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -1,5 +1,22 @@
 import loadPolyfills from '../mastodon/load_polyfills';
 import { processBio } from '../glitch/util/bio_metadata';
+import ready from '../mastodon/ready';
+
+window.addEventListener('message', e => {
+  const data = e.data || {};
+
+  if (!window.parent || data.type !== 'setHeight') {
+    return;
+  }
+
+  ready(() => {
+    window.parent.postMessage({
+      type: 'setHeight',
+      id: data.id,
+      height: document.getElementsByTagName('html')[0].scrollHeight,
+    }, '*');
+  });
+});
 
 function main() {
   const { length } = require('stringz');
@@ -7,13 +24,18 @@ function main() {
   const { delegate } = require('rails-ujs');
   const emojify = require('../mastodon/emoji').default;
   const { getLocale } = require('../mastodon/locales');
-  const ready = require('../mastodon/ready').default;
-
   const { localeData } = getLocale();
+  const VideoContainer = require('../mastodon/containers/video_container').default;
+  const MediaGalleryContainer = require('../mastodon/containers/media_gallery_container').default;
+  const CardContainer = require('../mastodon/containers/card_container').default;
+  const React = require('react');
+  const ReactDOM = require('react-dom');
+
   localeData.forEach(IntlRelativeFormat.__addLocaleData);
 
   ready(() => {
     const locale = document.documentElement.lang;
+
     const dateTimeFormat = new Intl.DateTimeFormat(locale, {
       year: 'numeric',
       month: 'long',
@@ -21,6 +43,7 @@ function main() {
       hour: 'numeric',
       minute: 'numeric',
     });
+
     const relativeFormat = new IntlRelativeFormat(locale);
 
     [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
@@ -30,30 +53,39 @@ function main() {
     [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
       const datetime = new Date(content.getAttribute('datetime'));
       const formattedDate = dateTimeFormat.format(datetime);
+
       content.title = formattedDate;
       content.textContent = formattedDate;
     });
 
     [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
       const datetime = new Date(content.getAttribute('datetime'));
+
+      content.title = dateTimeFormat.format(datetime);
       content.textContent = relativeFormat.format(datetime);
     });
-  });
 
-  delegate(document, '.video-player video', 'click', ({ target }) => {
-    if (target.paused) {
-      target.play();
-    } else {
-      target.pause();
-    }
-  });
+    [].forEach.call(document.querySelectorAll('.logo-button'), (content) => {
+      content.addEventListener('click', (e) => {
+        e.preventDefault();
+        window.open(e.target.href, 'mastodon-intent', 'width=400,height=400,resizable=no,menubar=no,status=no,scrollbars=yes');
+      });
+    });
 
-  delegate(document, '.activity-stream .media-spoiler-wrapper .media-spoiler', 'click', function() {
-    this.parentNode.classList.add('media-spoiler-wrapper__visible');
-  });
+    [].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="MediaGallery"]'), (content) => {
+      const props = JSON.parse(content.getAttribute('data-props'));
+      ReactDOM.render(<MediaGalleryContainer locale={locale} {...props} />, content);
+    });
 
-  delegate(document, '.activity-stream .media-spoiler-wrapper .spoiler-button', 'click', function() {
-    this.parentNode.classList.remove('media-spoiler-wrapper__visible');
+    [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
+      const props = JSON.parse(content.getAttribute('data-props'));
+      ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
+    });
   });
 
   delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
@@ -66,6 +98,7 @@ function main() {
 
   delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
     const contentEl = target.parentNode.parentNode.querySelector('.e-content');
+
     if (contentEl.style.display === 'block') {
       contentEl.style.display = 'none';
       target.parentNode.style.marginBottom = 0;
@@ -73,11 +106,13 @@ function main() {
       contentEl.style.display = 'block';
       target.parentNode.style.marginBottom = null;
     }
+
     return false;
   });
 
   delegate(document, '.account_display_name', 'input', ({ target }) => {
     const nameCounter = document.querySelector('.name-counter');
+
     if (nameCounter) {
       nameCounter.textContent = 30 - length(target.value);
     }
@@ -85,6 +120,7 @@ function main() {
 
   delegate(document, '.account_note', 'input', ({ target }) => {
     const noteCounter = document.querySelector('.note-counter');
+
     if (noteCounter) {
       const noteWithoutMetadata = processBio(target.value).text;
       noteCounter.textContent = 500 - length(noteWithoutMetadata);
@@ -94,14 +130,16 @@ function main() {
   delegate(document, '#account_avatar', 'change', ({ target }) => {
     const avatar = document.querySelector('.card.compact .avatar img');
     const [file] = target.files || [];
-    const url = URL.createObjectURL(file);
+    const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
+
     avatar.src = url;
   });
 
   delegate(document, '#account_header', 'change', ({ target }) => {
     const header = document.querySelector('.card.compact');
     const [file] = target.files || [];
-    const url = URL.createObjectURL(file);
+    const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
+
     header.style.backgroundImage = `url(${url})`;
   });
 }
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
new file mode 100644
index 000000000..51e4ae38b
--- /dev/null
+++ b/app/javascript/packs/share.js
@@ -0,0 +1,24 @@
+import loadPolyfills from '../mastodon/load_polyfills';
+
+require.context('../images/', true);
+
+function loaded() {
+  const ComposeContainer = require('../mastodon/containers/compose_container').default;
+  const React = require('react');
+  const ReactDOM = require('react-dom');
+  const mountNode = document.getElementById('mastodon-compose');
+
+  if (mountNode !== null) {
+    const props = JSON.parse(mountNode.getAttribute('data-props'));
+    ReactDOM.render(<ComposeContainer {...props} />, mountNode);
+  }
+}
+
+function main() {
+  const ready = require('../mastodon/ready').default;
+  ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+  console.error(error);
+});
diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss
index 66da44086..2adcb5ba2 100644
--- a/app/javascript/styles/about.scss
+++ b/app/javascript/styles/about.scss
@@ -1,52 +1,96 @@
-.about-body {
-  .wrapper {
-    max-width: 600px;
-    margin: 0 auto;
+.landing-page {
+  p,
+  li {
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    margin-bottom: 12px;
     color: $ui-primary-color;
-    padding-top: 50px;
-    padding-bottom: 50px;
 
-    &.thicc {
-      max-width: 800px;
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
     }
   }
 
+  em {
+    display: inline;
+    margin: 0;
+    padding: 0;
+    font-weight: 500;
+    background: transparent;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+    color: lighten($ui-primary-color, 10%);
+  }
+
   h1 {
-    font: 46px/52px 'mastodon-font-sans-serif', sans-serif;
-    font-weight: 600;
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 26px;
+    line-height: 30px;
+    font-weight: 500;
     margin-bottom: 20px;
-    color: $ui-highlight-color;
-    padding: 20px 0;
+    color: $ui-secondary-color;
 
-    img {
-      margin-bottom: -5px;
-      margin-right: 5px;
-      width: 46px;
-      height: 46px;
+    small {
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      display: block;
+      font-size: 18px;
+      font-weight: 400;
+      color: $ui-base-lighter-color;
     }
   }
 
   h2 {
     font-family: 'mastodon-font-display', sans-serif;
-    font-size: 24px;
-    line-height: 28px;
-    font-weight: 400;
+    font-size: 22px;
+    line-height: 26px;
+    font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $ui-secondary-color;
   }
 
   h3 {
     font-family: 'mastodon-font-display', sans-serif;
-    font-size: 20px;
-    line-height: 28px;
-    font-weight: 400;
+    font-size: 18px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h4 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 16px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h5 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 14px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h6 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 12px;
+    line-height: 24px;
+    font-weight: 500;
     margin-bottom: 20px;
     color: $ui-secondary-color;
   }
 
   ul,
   ol {
-    list-style: inherit;
     margin-left: 20px;
 
     &[type='a'] {
@@ -58,219 +102,30 @@
     }
   }
 
-  li > ol,
-  li > ul {
-    margin-top: 20px;
-  }
-
-  p,
-  li {
-    font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
-    font-weight: 400;
-    margin-bottom: 12px;
-
-    a {
-      color: $ui-highlight-color;
-      text-decoration: underline;
-    }
-  }
-
-  em {
-    display: inline-block;
-    padding: 7px 7px 5px;
-    margin: 0 2px;
-    background: $ui-primary-color;
-    color: $ui-base-color;
-    font: 16px/16px 'mastodon-font-sans-serif', sans-serif;
-    font-weight: 300;
-  }
-
-  .screenshot {
-    box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
-    margin-bottom: 26px;
-
-    img {
-      max-width: 100%;
-      height: auto;
-      display: block;
-    }
+  ul {
+    list-style: disc;
   }
 
-  .actions {
-    overflow: hidden;
-    margin-bottom: 20px;
-
-    .info {
-      float: right;
-      text-align: right;
-      line-height: 36px;
-
-      a {
-        color: $ui-primary-color;
-        text-decoration: underline;
-      }
-    }
+  ol {
+    list-style: decimal;
   }
 
-  @media screen and (max-width: 625px) {
-    .wrapper {
-      padding: 20px;
-    }
+  li > ol,
+  li > ul {
+    margin-top: 6px;
   }
-}
-
-.information-board {
-  background: darken($ui-base-color, 4%);
-  padding: 20px 0;
-
-  .panel {
-    position: absolute;
-    width: 280px;
-    box-sizing: border-box;
-    background: darken($ui-base-color, 8%);
-    padding: 20px;
-    padding-top: 10px;
-    border-radius: 4px 4px 0 0;
-    right: 0;
-    bottom: -40px;
-
-    .panel-header {
-      font-family: 'mastodon-font-display', sans-serif;
-      font-size: 14px;
-      line-height: 24px;
-      font-weight: 500;
-      color: $ui-base-lighter-color;
-      padding-bottom: 5px;
-      margin-bottom: 15px;
-      border-bottom: 1px solid lighten($ui-base-color, 4%);
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      overflow: hidden;
-
-      a,
-      span {
-        font-weight: 400;
-        color: lighten($ui-base-color, 34%);
-      }
 
-      a {
-        text-decoration: none;
-      }
-    }
+  hr {
+    border-color: rgba($ui-base-lighter-color, .6);
   }
 
   .container {
-    position: relative;
-    padding-right: 280px + 15px;
-  }
-
-  .information-board-sections {
-    display: flex;
-    justify-content: space-between;
-    flex-wrap: wrap;
-  }
-
-  .section {
-    flex: 1 0 0;
-    font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
-    text-align: right;
-    padding: 10px 15px;
-
-    span,
-    strong {
-      display: block;
-    }
-
-    span {
-      font-size: 16px;
-
-      &:last-child {
-        color: $ui-secondary-color;
-      }
-    }
-
-    strong {
-      font-weight: 500;
-      font-size: 32px;
-      line-height: 48px;
-      color: $primary-text-color;
-    }
-  }
-}
-
-.owner {
-  text-align: center;
-
-  .avatar {
-    @include avatar-size(80px);
+    width: 100%;
+    box-sizing: border-box;
+    max-width: 800px;
     margin: 0 auto;
-    margin-bottom: 15px;
-
-    img {
-      @include avatar-radius();
-      @include avatar-size(80px);
-      display: block;
-    }
-  }
-
-  .name {
-    font-size: 14px;
-
-    a {
-      display: block;
-      color: $primary-text-color;
-      text-decoration: none;
-
-      &:hover {
-        .display_name {
-          text-decoration: underline;
-        }
-      }
-    }
-
-    .username {
-      display: block;
-      color: $ui-primary-color;
-    }
-  }
-}
-
-.features-list__row {
-  display: flex;
-  padding: 10px 0;
-  justify-content: space-between;
-
-  &:first-child {
-    padding-top: 0;
-  }
-
-  .visual {
-    flex: 0 0 auto;
-    display: flex;
-    align-items: center;
-    margin-left: 15px;
-
-    .fa {
-      display: block;
-      color: $ui-primary-color;
-      font-size: 48px;
-    }
-  }
-
-  .text {
-    font-size: 16px;
-    line-height: 30px;
-    color: $ui-base-lighter-color;
-
-    h6 {
-      font-weight: 500;
-      color: $ui-primary-color;
-    }
+    word-wrap: break-word;
   }
-}
-
-.landing-page {
-  $lp-par-color: lighten($ui-base-color, 36%);
 
   .header-wrapper {
     padding-top: 15px;
@@ -283,14 +138,17 @@
       padding-bottom: 15px;
 
       .hero .heading {
-        padding-bottom: 30px;
-
-        p, li {
-          color: lighten($ui-base-color, 50%);
-        }
+        padding-bottom: 20px;
+        font-family: 'mastodon-font-sans-serif', sans-serif;
+        font-size: 16px;
+        font-weight: 400;
+        font-size: 16px;
+        line-height: 30px;
+        color: $ui-primary-color;
 
-        li {
-          margin: 2px 0;
+        a {
+          color: $ui-highlight-color;
+          text-decoration: underline;
         }
       }
     }
@@ -315,17 +173,6 @@
     }
   }
 
-  p,
-  li {
-    font: inherit;
-    font-weight: inherit;
-    margin-bottom: 0;
-  }
-
-  hr {
-    border-color: rgba($ui-base-lighter-color, .6);
-  }
-
   .header {
     line-height: 30px;
     overflow: hidden;
@@ -335,6 +182,62 @@
       justify-content: space-between;
     }
 
+    .links {
+      position: relative;
+      z-index: 4;
+
+      a {
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        color: $ui-primary-color;
+        text-decoration: none;
+        padding: 12px 16px;
+        line-height: 32px;
+        font-family: 'mastodon-font-display', sans-serif;
+        font-weight: 500;
+        font-size: 14px;
+
+        &:hover {
+          color: $ui-secondary-color;
+        }
+      }
+
+      .brand {
+        a {
+          padding-left: 0;
+          padding-right: 0;
+          color: $white;
+        }
+
+        img {
+          height: 32px;
+          position: relative;
+          top: 4px;
+          left: -10px;
+        }
+      }
+
+      ul {
+        list-style: none;
+        margin: 0;
+
+        li {
+          display: inline-block;
+          vertical-align: bottom;
+          margin: 0;
+
+          &:first-child a {
+            padding-left: 0;
+          }
+
+          &:last-child a {
+            padding-right: 0;
+          }
+        }
+      }
+    }
+
     .hero {
       margin-top: 50px;
       align-items: center;
@@ -387,6 +290,12 @@
         }
       }
 
+      .heading {
+        position: relative;
+        z-index: 4;
+        padding-bottom: 150px;
+      }
+
       .simple_form,
       .closed-registrations-message {
         background: darken($ui-base-color, 4%);
@@ -408,12 +317,6 @@
         }
       }
 
-      .heading {
-        position: relative;
-        z-index: 4;
-        padding-bottom: 150px;
-      }
-
       .closed-registrations-message {
         min-height: 330px;
         display: flex;
@@ -421,136 +324,140 @@
         justify-content: space-between;
       }
     }
+  }
 
-    .links {
+  .about-short {
+    background: darken($ui-base-color, 4%);
+    padding: 50px 0 30px;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    color: $ui-primary-color;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+    }
+  }
+
+  .information-board {
+    background: darken($ui-base-color, 4%);
+    padding: 20px 0;
+
+    .container {
       position: relative;
-      z-index: 4;
+      padding-right: 280px + 15px;
+    }
 
-      ul {
-        list-style: none;
-        margin: 0;
+    .information-board-sections {
+      display: flex;
+      justify-content: space-between;
+      flex-wrap: wrap;
+    }
 
-        li {
-          display: inline-block;
-          vertical-align: bottom;
-          margin: 0;
+    .section {
+      flex: 1 0 0;
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      font-size: 16px;
+      line-height: 28px;
+      color: $primary-text-color;
+      text-align: right;
+      padding: 10px 15px;
 
-          &:last-child a {
-            padding-right: 0;
-          }
-        }
+      span,
+      strong {
+        display: block;
       }
 
-      a {
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        color: $ui-primary-color;
-        text-decoration: none;
-        padding: 12px 16px;
-        line-height: 32px;
-        font-family: 'mastodon-font-display', sans-serif;
-        font-weight: 500;
-        font-size: 14px;
-
-        &:hover {
+      span {
+        &:last-child {
           color: $ui-secondary-color;
         }
       }
 
-      .brand {
-        a {
-          padding-left: 0;
-          padding-right: 0;
-          color: $white;
-        }
-
-        img {
-          height: 32px;
-          position: relative;
-          top: 4px;
-          left: -10px;
-        }
+      strong {
+        font-weight: 500;
+        font-size: 32px;
+        line-height: 48px;
       }
     }
-  }
-
-  .container {
-    width: 100%;
-    box-sizing: border-box;
-    max-width: 800px;
-    margin: 0 auto;
-  }
-
-  .wrapper {
-    max-width: 800px;
-    margin: 0 auto;
-    padding: 0;
-  }
-
-  .learn-more-cta, .extended-description {
-    padding: 50px 0;
-    font-weight: 400;
-    color: $lp-par-color;
-    font: 16px/1.6 'mastodon-font-sans-serif', sans-serif;
 
-    ul,
-    ol {
-      list-style: inherit;
-      margin-left: 20px;
+    .panel {
+      position: absolute;
+      width: 280px;
+      box-sizing: border-box;
+      background: darken($ui-base-color, 8%);
+      padding: 20px;
+      padding-top: 10px;
+      border-radius: 4px 4px 0 0;
+      right: 0;
+      bottom: -40px;
 
-      &[type='a'] {
-        list-style-type: lower-alpha;
-      }
+      .panel-header {
+        font-family: 'mastodon-font-display', sans-serif;
+        font-size: 14px;
+        line-height: 24px;
+        font-weight: 500;
+        color: $ui-primary-color;
+        padding-bottom: 5px;
+        margin-bottom: 15px;
+        border-bottom: 1px solid lighten($ui-base-color, 4%);
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        overflow: hidden;
+
+        a,
+        span {
+          font-weight: 400;
+          color: darken($ui-primary-color, 10%);
+        }
 
-      &[type='i'] {
-        list-style-type: lower-roman;
+        a {
+          text-decoration: none;
+        }
       }
     }
 
-    li > ol,
-    li > ul {
-      margin-top: 20px;
-    }
+    .owner {
+      text-align: center;
 
-    p,
-    li {
-      color: $lp-par-color;
-      margin-bottom: 6px;
+      .avatar {
+        width: 80px;
+        height: 80px;
+        margin: 0 auto;
+        margin-bottom: 15px;
 
-      a {
-        color: $ui-highlight-color;
-        text-decoration: underline;
+        img {
+          display: block;
+          width: 80px;
+          height: 80px;
+          border-radius: 48px;
+        }
       }
-    }
-
-    li {
-      margin: 2px 0;
-    }
-  }
 
-  .learn-more-cta {
-    background: darken($ui-base-color, 4%);
-    padding: 50px 0;
-    p {
-      font-size: 16px;
-      line-height: 28px;
-    }
-  }
+      .name {
+        font-size: 14px;
 
-  h3 {
-    font-family: 'mastodon-font-display', sans-serif;
-    font-size: 16px;
-    line-height: 24px;
-    font-weight: 500;
-    margin-bottom: 20px;
-    color: $ui-primary-color;
-  }
+        a {
+          display: block;
+          color: $primary-text-color;
+          text-decoration: none;
+
+          &:hover {
+            .display_name {
+              text-decoration: underline;
+            }
+          }
+        }
 
-  p {
-    font-size: 16px;
-    line-height: 28px;
-    color: $lp-par-color;
+        .username {
+          display: block;
+          color: $ui-primary-color;
+        }
+      }
+    }
   }
 
   .features {
@@ -559,100 +466,121 @@
     .container {
       display: flex;
     }
-  }
 
-  #mastodon-timeline {
-    display: flex;
-    -webkit-overflow-scrolling: touch;
-    -ms-overflow-style: -ms-autohiding-scrollbar;
-    font-family: 'mastodon-font-sans-serif', sans-serif;
-    font-size: 13px;
-    line-height: 18px;
-    font-weight: 400;
-    color: $primary-text-color;
-    width: 330px;
-    margin-right: 30px;
-    flex: 0 0 auto;
-    background: $ui-base-color;
-    overflow: hidden;
-    box-shadow: 0 0 6px rgba($black, 0.1);
+    #mastodon-timeline {
+      display: flex;
+      -webkit-overflow-scrolling: touch;
+      -ms-overflow-style: -ms-autohiding-scrollbar;
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      font-size: 13px;
+      line-height: 18px;
+      font-weight: 400;
+      color: $primary-text-color;
+      width: 330px;
+      margin-right: 30px;
+      flex: 0 0 auto;
+      background: $ui-base-color;
+      overflow: hidden;
+      box-shadow: 0 0 6px rgba($black, 0.1);
+
+      .column-header {
+        color: inherit;
+        font-family: inherit;
+        font-size: 16px;
+        line-height: inherit;
+        font-weight: inherit;
+        margin: 0;
+        padding: 15px;
+      }
 
-    .column-header {
-      color: inherit;
-      font-family: inherit;
-      font-size: 16px;
-      line-height: inherit;
-      font-weight: inherit;
-      margin: 0;
-      padding: 15px;
-    }
+      .column {
+        padding: 0;
+        border-radius: 4px;
+        overflow: hidden;
+      }
 
-    .column {
-      padding: 0;
-      border-radius: 4px;
-      overflow: hidden;
-    }
+      .scrollable {
+        height: 400px;
+      }
 
-    .scrollable {
-      height: 400px;
-    }
+      p {
+        font-size: inherit;
+        line-height: inherit;
+        font-weight: inherit;
+        color: $primary-text-color;
+        margin-bottom: 20px;
 
-    p {
-      font-size: inherit;
-      line-height: inherit;
-      font-weight: inherit;
-      color: $primary-text-color;
-      margin-bottom: 20px;
+        &:last-child {
+          margin-bottom: 0;
+        }
 
-      &:last-child {
-        margin-bottom: 0;
+        a {
+          color: $ui-secondary-color;
+          text-decoration: none;
+        }
       }
+    }
 
-      a {
-        color: $ui-secondary-color;
-        text-decoration: none;
+    .about-mastodon {
+      max-width: 675px;
+
+      p {
+        margin-bottom: 20px;
       }
-    }
-  }
 
-  .about-mastodon {
-    max-width: 675px;
+      .features-list {
+        margin-top: 20px;
 
-    p {
-      margin-bottom: 20px;
-    }
+        .features-list__row {
+          display: flex;
+          padding: 10px 0;
+          justify-content: space-between;
 
-    .features-list {
-      margin-top: 20px;
-    }
-  }
+          &:first-child {
+            padding-top: 0;
+          }
 
-  em {
-    display: inline;
-    margin: 0;
-    padding: 0;
-    font-weight: 500;
-    background: transparent;
-    font-family: inherit;
-    font-size: inherit;
-    line-height: inherit;
-    color: $ui-primary-color;
+          .visual {
+            flex: 0 0 auto;
+            display: flex;
+            align-items: center;
+            margin-left: 15px;
+
+            .fa {
+              display: block;
+              color: $ui-primary-color;
+              font-size: 48px;
+            }
+          }
+
+          .text {
+            font-size: 16px;
+            line-height: 30px;
+            color: $ui-primary-color;
+
+            h6 {
+              font-size: inherit;
+              line-height: inherit;
+              margin-bottom: 0;
+            }
+          }
+        }
+      }
+    }
   }
 
-  h1 {
-    font-family: 'mastodon-font-display', sans-serif;
-    font-size: 26px;
+  .extended-description {
+    padding: 50px 0;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
     line-height: 30px;
-    margin-bottom: 0;
-    font-weight: 500;
-    color: $ui-secondary-color;
+    color: $ui-primary-color;
 
-    small {
-      font-family: 'mastodon-font-sans-serif', sans-serif;
-      display: block;
-      font-size: 18px;
-      font-weight: 400;
-      color: $ui-base-lighter-color;
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
     }
   }
 
@@ -676,8 +604,15 @@
       padding: 0 20px;
     }
 
-    .information-board .container {
-      padding-right: 20px;
+    .information-board {
+
+      .container {
+        padding-right: 20px;
+      }
+
+      .section {
+        text-align: center;
+      }
 
       .panel {
         position: static;
@@ -691,10 +626,6 @@
       }
     }
 
-    .information-board .section {
-      text-align: center;
-    }
-
     .header-wrapper .mascot {
       left: 20px;
     }
@@ -710,8 +641,12 @@
     .header-wrapper {
       padding-top: 0;
 
+      &.compact {
+        padding-bottom: 0;
+      }
+
       &.compact .hero .heading {
-        padding-bottom: 20px;
+        text-align: initial;
       }
     }
 
@@ -720,51 +655,41 @@
       display: block;
     }
 
-    .links {
-      padding-top: 15px;
-      background: darken($ui-base-color, 4%);
-    }
-
     .header {
 
-      .hero {
-        margin-top: 30px;
-        padding: 0;
+      .links {
+        padding-top: 15px;
+        background: darken($ui-base-color, 4%);
 
-        .heading {
-          padding: 0 20px 20px;
+        a {
+          padding: 12px 8px;
         }
-      }
-
-      .floats {
-        display: none;
-      }
 
-      .heading,
-      .nav {
-        text-align: center;
-      }
+        .nav {
+          display: flex;
+          flex-flow: row wrap;
+          justify-content: space-around;
+        }
 
-      .nav {
-        display: flex;
-        flex-flow: row wrap;
-        justify-content: space-around;
+        .brand img {
+          left: 0;
+          top: 0;
+        }
       }
 
-      .links a {
-        padding: 12px 8px;
-      }
+      .hero {
+        margin-top: 30px;
+        padding: 0;
 
-      .heading h1 {
-        padding: 30px 0;
-      }
+        .floats {
+          display: none;
+        }
 
-      .links .brand img {
-        left: 0;
-        top: 0;
-      }
+        .heading {
+          padding: 30px 20px;
+          text-align: center;
+        }
 
-      .hero {
         .simple_form,
         .closed-registrations-message {
           background: darken($ui-base-color, 8%);
@@ -775,7 +700,7 @@
       }
     }
 
-    #mastodon-timeline {
+    .features #mastodon-timeline {
       height: 70vh;
       width: 100%;
       margin-bottom: 50px;
diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss
index 3d5c1a692..744650554 100644
--- a/app/javascript/styles/accounts.scss
+++ b/app/javascript/styles/accounts.scss
@@ -1,34 +1,48 @@
 .card {
-  display: flex;
-  background: $ui-base-color;
+  background-color: lighten($ui-base-color, 4%);
   background-size: cover;
   background-position: center;
   border-radius: 4px 4px 0 0;
   box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
   overflow: hidden;
+  position: relative;
+  display: flex;
+
+  &::after {
+    background: rgba(darken($ui-base-color, 8%), 0.5);
+    display: block;
+    content: "";
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 1;
+  }
 
-  @media screen and (max-width: 700px) {
+  @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
   }
 
-  .details {
+  .card__illustration {
+    padding: 60px 0;
     position: relative;
-    padding: 60px 0 0;
-    text-align: center;
-    flex: auto;
+    flex: 1 1 auto;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
 
-    &::after {
-      background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8));
-      display: block;
-      content: "";
-      position: absolute;
-      left: 0;
-      top: 0;
-      width: 100%;
-      height: 100%;
-      z-index: 1;
-    }
+  .card__bio {
+    max-width: 260px;
+    flex: 1 1 auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    background: rgba(darken($ui-base-color, 8%), 0.8);
+    position: relative;
+    z-index: 2;
   }
 
   &.compact {
@@ -46,14 +60,15 @@
 
   .name {
     display: block;
-    position: relative;
     font-size: 20px;
     line-height: 18px * 1.5;
     color: $primary-text-color;
+    padding: 10px 15px;
+    padding-bottom: 0;
     font-weight: 500;
-    text-align: center;
-    text-shadow: 0 0 2px $base-shadow-color;
+    position: relative;
     z-index: 2;
+    margin-bottom: 30px;
 
     small {
       display: block;
@@ -64,56 +79,102 @@
   }
 
   .avatar {
-    position: relative;
-    @include avatar-size(120px);
+    width: 120px;
     margin: 0 auto;
-    margin-bottom: 15px;
+    position: relative;
     z-index: 2;
 
     img {
-      @include avatar-radius();
-      @include avatar-size(120px);
+      width: 120px;
+      height: 120px;
       display: block;
+      border-radius: 120px;
+      box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
     }
   }
 
   .controls {
     position: absolute;
-    top: 10px;
-    right: 10px;
+    top: 15px;
+    left: 15px;
     z-index: 2;
+
+    .icon-button {
+      color: rgba($white, 0.8);
+      text-decoration: none;
+      font-size: 13px;
+      line-height: 13px;
+      font-weight: 500;
+
+      .fa {
+        font-weight: 400;
+        margin-right: 5px;
+      }
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: $white;
+      }
+    }
+  }
+
+  .roles {
+    margin-bottom: 30px;
+    padding: 0 15px;
   }
 
   .details-counters {
-    display: inline-flex;
-    position: relative;
+    margin-top: 30px;
+    display: flex;
     flex-direction: row;
-    margin: 15px 0;
-    z-index: 2;
+    width: 100%;
   }
 
   .counter {
-    width: 80px;
+    width: 33.3%;
+    box-sizing: border-box;
+    flex: 0 0 auto;
     color: $ui-primary-color;
     padding: 5px 10px 0;
+    margin-bottom: 10px;
+    border-right: 1px solid lighten($ui-base-color, 4%);
     cursor: default;
+    text-align: center;
     position: relative;
 
-    & + .counter {
-      border-left: 1px solid $ui-primary-color;
+    a {
+      display: block;
+    }
+
+    &:last-child {
+      border-right: 0;
     }
 
-    & > * {
-      opacity: .7;
-      transition: opacity .3s ease;
+    &::after {
+      display: block;
+      content: "";
+      position: absolute;
+      bottom: -10px;
+      left: 0;
+      width: 100%;
+      border-bottom: 4px solid $ui-primary-color;
+      opacity: 0.5;
+      transition: all 400ms ease;
     }
 
-    &.active > *, &:hover > * {
-      opacity: 1;
+    &.active {
+      &::after {
+        border-bottom: 4px solid $ui-highlight-color;
+        opacity: 1;
+      }
     }
 
-    a {
-      display: block;
+    &:hover {
+      &::after {
+        opacity: 1;
+        transition-duration: 100ms;
+      }
     }
 
     a {
@@ -123,87 +184,40 @@
 
     .counter-label {
       font-size: 12px;
-      text-transform: uppercase;
       display: block;
       margin-bottom: 5px;
-      text-shadow: 0 0 2px $base-shadow-color;
     }
 
     .counter-number {
       font-weight: 500;
       font-size: 18px;
       color: $primary-text-color;
+      font-family: 'mastodon-font-display', sans-serif;
     }
   }
 
   .bio {
-    position: relative;
     font-size: 14px;
     line-height: 18px;
-    margin: 15px 0;
-    padding: 5px 10px;
+    padding: 0 15px;
     color: $ui-secondary-color;
-    z-index: 2;
-  }
-
-  .metadata {
-    position: relative;
-    min-width: 180px;
-    max-width: 40%;
-    background: rgba($base-shadow-color, 0.8);
-    color: $primary-text-color;
-    text-align: left;
-    overflow-y: auto;
-    white-space: pre-wrap;
-    z-index: 3;
-
-    .metadata-item {
-      border-bottom: 1px $ui-primary-color solid;
-      padding: 15px 10px;
-      font-size: 18px;
-      line-height: 24px;
-      overflow: hidden;
-      text-overflow: ellipsis;
-
-      a {
-        color: $ui-highlight-color;
-        text-decoration: none;
-
-        &:hover {
-          text-decoration: underline;
-        }
-      }
-
-      b {
-        display: block;
-        font-size: 12px;
-        line-height: 16px;
-        text-transform: uppercase;
-        color: $ui-primary-color;
-
-        a {
-          color: $ui-primary-color;
-        }
-      }
-    }
   }
-}
-
-
-
-@media screen and (max-width: 500px) {
 
-  .card {
+  @media screen and (max-width: 480px) {
     display: block;
 
-    .metadata {
+    .card__bio {
       max-width: none;
-      background: $base-shadow-color;
-      border-top: 1px $ui-primary-color solid;
+    }
 
-      .metadata-item {
-        padding: 15px 20px;
-      }
+    .name,
+    .roles {
+      text-align: center;
+      margin-bottom: 15px;
+    }
+
+    .bio {
+      margin-bottom: 15px;
     }
   }
 }
@@ -282,7 +296,9 @@
     }
 
     .next,
-    .prev {
+    .prev,
+    .next a,
+    .prev a {
       display: inline-block;
     }
   }
@@ -290,15 +306,17 @@
 
 .accounts-grid {
   box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
-  background: $simple-background-color;
+  background: darken($simple-background-color, 8%);
   border-radius: 0 0 4px 4px;
-  padding: 20px 10px;
+  padding: 20px 5px;
   padding-bottom: 10px;
   overflow: hidden;
   display: flex;
   flex-wrap: wrap;
+  z-index: 2;
+  position: relative;
 
-  @media screen and (max-width: 700px) {
+  @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
   }
@@ -306,35 +324,64 @@
   .account-grid-card {
     box-sizing: border-box;
     width: 335px;
-    border: 1px solid $ui-secondary-color;
+    background: $simple-background-color;
     border-radius: 4px;
     color: $ui-base-color;
-    margin-bottom: 10px;
+    margin: 0 5px 10px;
+    position: relative;
 
-    &:nth-child(odd) {
-      margin-right: 10px;
+    @media screen and (max-width: 740px) {
+      width: calc(100% - 10px);
     }
 
     .account-grid-card__header {
       overflow: hidden;
-      padding: 10px;
-      border-bottom: 1px solid $ui-secondary-color;
+      height: 100px;
+      border-radius: 4px 4px 0 0;
+      background-color: lighten($ui-base-color, 4%);
+      background-size: cover;
+      background-position: center;
+      position: relative;
+
+      &::after {
+        background: rgba(darken($ui-base-color, 8%), 0.5);
+        display: block;
+        content: "";
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+        z-index: 1;
+      }
+    }
+
+    .account-grid-card__avatar {
+      box-sizing: border-box;
+      padding: 15px;
+      position: absolute;
+      z-index: 2;
+      top: 100px - (40px + 2px);
+      left: -2px;
     }
 
     .avatar {
-      @include avatar-size(60px);
-      float: left;
-      margin-right: 15px;
+      width: 80px;
+      height: 80px;
 
       img {
-        @include avatar-radius();
-        @include avatar-size(60px);
         display: block;
+        width: 80px;
+        height: 80px;
+        border-radius: 80px;
+        border: 2px solid $simple-background-color;
       }
     }
 
     .name {
+      padding: 15px;
       padding-top: 10px;
+      padding-left: 15px + 80px + 15px;
 
       a {
         display: block;
@@ -342,6 +389,7 @@
         text-decoration: none;
         text-overflow: ellipsis;
         overflow: hidden;
+        font-weight: 500;
 
         &:hover {
           .display_name {
@@ -352,30 +400,38 @@
     }
 
     .display_name {
-      font-size: 14px;
+      font-size: 16px;
       display: block;
+      text-overflow: ellipsis;
+      overflow: hidden;
     }
 
     .username {
-      color: $ui-highlight-color;
+      color: lighten($ui-base-color, 34%);
+      font-size: 14px;
+      font-weight: 400;
     }
 
     .note {
-      padding: 10px;
+      padding: 10px 15px;
       padding-top: 15px;
-      color: $ui-primary-color;
+      box-sizing: border-box;
+      color: lighten($ui-base-color, 26%);
       word-wrap: break-word;
+      min-height: 80px;
     }
   }
 }
 
 .nothing-here {
+  width: 100%;
+  display: block;
   color: $ui-primary-color;
   font-size: 14px;
   font-weight: 500;
   text-align: center;
-  padding: 15px 0;
-  padding-bottom: 25px;
+  padding: 60px 0;
+  padding-top: 55px;
   cursor: default;
 }
 
@@ -396,14 +452,15 @@
     }
 
     & > div {
-      @include avatar-size(48px);
       float: left;
       margin-right: 10px;
+      width: 48px;
+      height: 48px;
     }
 
     .avatar {
-      @include avatar-radius();
       display: block;
+      border-radius: 4px;
     }
 
     .display-name {
@@ -439,3 +496,43 @@
     color: $ui-base-color;
   }
 }
+
+.activity-stream-tabs {
+  background: $simple-background-color;
+  border-bottom: 1px solid $ui-secondary-color;
+  position: relative;
+  z-index: 2;
+
+  a {
+    display: inline-block;
+    padding: 15px;
+    text-decoration: none;
+    color: $ui-highlight-color;
+    text-transform: uppercase;
+    font-weight: 500;
+
+    &:hover,
+    &:active,
+    &:focus {
+      color: lighten($ui-highlight-color, 8%);
+    }
+
+    &.active {
+      color: $ui-base-color;
+      cursor: default;
+    }
+  }
+}
+
+.account-role {
+  display: inline-block;
+  padding: 4px 6px;
+  cursor: default;
+  border-radius: 3px;
+  font-size: 12px;
+  line-height: 12px;
+  font-weight: 500;
+  color: $success-green;
+  background-color: rgba($success-green, 0.1);
+  border: 1px solid rgba($success-green, 0.5);
+}
diff --git a/app/javascript/styles/admin.scss b/app/javascript/styles/admin.scss
index 4c3bbdfc5..87bc710af 100644
--- a/app/javascript/styles/admin.scss
+++ b/app/javascript/styles/admin.scss
@@ -32,7 +32,7 @@
 
       a {
         display: block;
-        padding: 15px 25px;
+        padding: 15px;
         color: rgba($primary-text-color, 0.7);
         text-decoration: none;
         transition: all 200ms linear;
@@ -61,6 +61,7 @@
 
         a {
           border: 0;
+          padding: 15px 35px;
 
           &.selected {
             color: $primary-text-color;
@@ -96,9 +97,17 @@
       margin-bottom: 40px;
     }
 
+    h3 {
+      color: $ui-secondary-color;
+      font-size: 20px;
+      line-height: 28px;
+      font-weight: 400;
+      margin-bottom: 30px;
+    }
+
     h6 {
       font-size: 16px;
-      color: $ui-primary-color;
+      color: $ui-secondary-color;
       line-height: 28px;
       font-weight: 400;
     }
@@ -123,10 +132,10 @@
     }
 
     .muted-hint {
-      color: lighten($ui-base-color, 27%);
+      color: $ui-primary-color;
 
       a {
-        color: $ui-primary-color;
+        color: $ui-highlight-color;
       }
     }
 
@@ -139,15 +148,23 @@
   .simple_form {
     max-width: 400px;
 
-    .label_input {
-      label.select {
-        width: 50%;
-      }
+    &.edit_user,
+    &.new_form_admin_settings,
+    &.new_form_two_factor_confirmation,
+    &.new_form_delete_confirmation,
+    &.new_import,
+    &.new_domain_block,
+    &.edit_domain_block {
+      max-width: none;
+    }
 
-      select {
-        width: 50%;
-        float: right;
-      }
+    .form_two_factor_confirmation_code,
+    .form_delete_confirmation_password {
+      max-width: 400px;
+    }
+
+    .actions {
+      max-width: 400px;
     }
   }
 
@@ -181,11 +198,15 @@
 
 .filters {
   display: flex;
-  margin-bottom: 20px;
+  flex-wrap: wrap;
 
   .filter-subset {
     flex: 0 0 auto;
-    margin-right: 40px;
+    margin: 0 40px 10px 0;
+
+    &:last-child {
+      margin-bottom: 20px;
+    }
 
     ul {
       margin-top: 5px;
@@ -227,27 +248,25 @@
 
 .report-accounts {
   display: flex;
+  flex-wrap: wrap;
   margin-bottom: 20px;
 }
 
 .report-accounts__item {
-  flex: 1 1 0;
   display: flex;
+  flex: 250px;
   flex-direction: column;
+  margin: 0 5px;
 
   & > strong {
     display: block;
-    margin-bottom: 10px;
+    margin: 0 0 10px -5px;
     font-weight: 500;
     font-size: 14px;
     line-height: 18px;
     color: $ui-secondary-color;
   }
 
-  &:first-child {
-    margin-right: 10px;
-  }
-
   .account-card {
     flex: 1 1 auto;
   }
@@ -261,6 +280,11 @@
   .activity-stream {
     flex: 2 0 0;
     margin-right: 20px;
+    max-width: calc(100% - 60px);
+
+    .entry {
+      border-radius: 4px;
+    }
   }
 }
 
@@ -280,18 +304,25 @@
 
 .batch-form-box {
   display: flex;
-  margin-bottom: 10px;
+  flex-wrap: wrap;
+  margin-bottom: 5px;
 
   #form_status_batch_action {
-    margin-right: 5px;
+    margin: 0 5px 5px 0;
     font-size: 14px;
   }
 
+  input.button {
+    margin: 0 5px 5px 0;
+  }
+
   .media-spoiler-toggle-buttons {
     margin-left: auto;
 
     .button {
       overflow: visible;
+      margin: 0 0 5px 5px;
+      float: right;
     }
   }
 }
diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss
index 33c7783f3..e35937be1 100644
--- a/app/javascript/styles/application.scss
+++ b/app/javascript/styles/application.scss
@@ -13,6 +13,7 @@
 @import 'accounts';
 @import 'stream_entries';
 @import 'components';
+@import 'emoji_picker';
 @import 'about';
 @import 'tables';
 @import 'admin';
diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss
index 182ea36a4..96f0023c3 100644
--- a/app/javascript/styles/basics.scss
+++ b/app/javascript/styles/basics.scss
@@ -7,13 +7,28 @@ body {
   line-height: 18px;
   font-weight: 400;
   color: $primary-text-color;
-  padding-bottom: 140px;
+  padding-bottom: 20px;
   text-rendering: optimizelegibility;
   font-feature-settings: "kern";
   text-size-adjust: none;
   -webkit-tap-highlight-color: rgba(0,0,0,0);
   -webkit-tap-highlight-color: transparent;
 
+  &.system-font {
+    // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
+    // -apple-system => Safari <11 specific
+    // BlinkMacSystemFont => Chrome <56 on macOS specific
+    // Segoe UI => Windows 7/8/10
+    // Oxygen => KDE
+    // Ubuntu => Unity/Ubuntu
+    // Cantarell => GNOME
+    // Fira Sans => Firefox OS
+    // Droid Sans => Older Androids (<4.0)
+    // Helvetica Neue => Older macOS <10.11
+    // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
+    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
+  }
+
   &.app-body {
     position: fixed;
     width: 100%;
@@ -30,6 +45,7 @@ body {
   &.embed {
     background: transparent;
     margin: 0;
+    padding-bottom: 0;
 
     .container {
       position: absolute;
@@ -47,8 +63,24 @@ body {
     padding: 0;
   }
 
-  @media screen and (max-width: 360px) {
-    padding-bottom: 0;
+  &.error {
+    text-align: center;
+    color: $ui-primary-color;
+    padding: 20px;
+
+    .dialog img {
+      display: block;
+      margin: 0 auto;
+      max-width: 470px;
+      width: 100%;
+      height: auto;
+    }
+
+    .dialog h1 {
+      font-size: 20px;
+      line-height: 28px;
+      font-weight: 400;
+    }
   }
 }
 
@@ -68,18 +100,3 @@ button {
   align-items: center;
   justify-content: center;
 }
-
-.system-font {
-  // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
-  // -apple-system => Safari <11 specific
-  // BlinkMacSystemFont => Chrome <56 on macOS specific
-  // Segoe UI => Windows 7/8/10
-  // Oxygen => KDE
-  // Ubuntu => Unity/Ubuntu
-  // Cantarell => GNOME
-  // Fira Sans => Firefox OS
-  // Droid Sans => Older Androids (<4.0)
-  // Helvetica Neue => Older macOS <10.11
-  // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
-  font-family: system-ui, -apple-system,BlinkMacSystemFont, "Segoe UI","Oxygen", "Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",mastodon-font-sans-serif, sans-serif;
-}
diff --git a/app/javascript/styles/boost.scss b/app/javascript/styles/boost.scss
index bcd97359a..b07b72f8e 100644
--- a/app/javascript/styles/boost.scss
+++ b/app/javascript/styles/boost.scss
@@ -16,21 +16,13 @@ button.icon-button i.fa-retweet {
 // Disabled variant
 button.icon-button.disabled 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='#{lighten($ui-base-color, 13%)}' stroke-width='0'/></svg>");
+    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 13%))}' stroke-width='0'/></svg>");
   }
 }
 
-// Darker disabled variant for DMs
+// Disabled variant for use with DMs
 .status-direct button.icon-button.disabled 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='#{lighten($ui-base-color, 16%)}' stroke-width='0'/></svg>");
+    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 16%))}' stroke-width='0'/></svg>");
   }
 }
-
-//  Mastodon gave us this one, but I'm not sure if it's better.  - @kibi@glitch.social
-
-/*
-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(lighten($ui-base-color, 13%))}' 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>");
-}
-*/
diff --git a/app/javascript/styles/compact_header.scss b/app/javascript/styles/compact_header.scss
index 27a67135f..90d98cc8c 100644
--- a/app/javascript/styles/compact_header.scss
+++ b/app/javascript/styles/compact_header.scss
@@ -3,9 +3,15 @@
     font-size: 24px;
     line-height: 28px;
     color: $ui-primary-color;
-    overflow: hidden;
     font-weight: 500;
     margin-bottom: 20px;
+    padding: 0 10px;
+    word-wrap: break-word;
+
+    @media screen and (max-width: 740px) {
+      text-align: center;
+      padding: 20px 10px 0;
+    }
 
     a {
       color: inherit;
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index fa604df5c..2f02af098 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -1,6 +1,14 @@
 @import 'variables';
 @import 'variables-glitch';
 
+@mixin fullwidth-gallery {
+  &.full-width {
+    margin-left: -22px;
+    margin-right: -22px;
+    width: inherit;
+  }
+}
+
 .app-body {
   -webkit-overflow-scrolling: touch;
   -ms-overflow-style: -ms-autohiding-scrollbar;
@@ -214,12 +222,16 @@
   }
 }
 
+.dropdown-menu {
+  position: absolute;
+}
+
 .dropdown--active .icon-button {
   color: $ui-highlight-color;
 }
 
 .dropdown--active::after {
-  @media screen and (min-width: 1025px) {
+  @media screen and (min-width: 631px) {
     content: "";
     display: block;
     position: absolute;
@@ -238,6 +250,8 @@
   line-height: 0;
   display: inline-block;
   width: 0;
+  height: 0;
+  position: absolute;
 }
 
 .ellipsis {
@@ -385,15 +399,14 @@
 .compose-form__autosuggest-wrapper {
   position: relative;
 
-  .emoji-picker__dropdown {
+  .emoji-picker-dropdown {
     position: absolute;
     right: 5px;
     top: 5px;
 
-    &.dropdown--active::after {
-      border-color: transparent transparent $base-border-color;
-      bottom: -1px;
-      right: 8px;
+    ::-webkit-scrollbar-track:hover,
+    ::-webkit-scrollbar-track:active {
+      background-color: rgba($base-overlay-background, 0.3);
     }
   }
 }
@@ -406,12 +419,30 @@
 .compose-form__publish-button-wrapper {
   overflow: hidden;
   padding-top: 10px;
+  white-space: nowrap;
+  display: flex;
+
+  button {
+    text-overflow: unset;
+  }
+}
+
+.compose-form__publish__side-arm {
+  padding: 0 !important;
+  width: 4em;
+  text-align: center;
+  margin-right: 2px;
+}
+
+.compose-form__publish__primary {
+  padding: 0 10px !important;
 }
 
 .emojione {
   display: inline-block;
   font-size: inherit;
   vertical-align: middle;
+  object-fit: contain;
   margin: -.2ex .15em .2ex;
   width: 16px;
   height: 16px;
@@ -458,78 +489,6 @@
   cursor: pointer;
 }
 
-// --- Extra clickable area in the status gutter ---
-.ui.wide {
-  @mixin xtraspaces-full {
-    height: calc(100% + 10px);
-    bottom: -40px;
-  }
-  @mixin xtraspaces-short {
-    height: calc(100% - 35px);
-    bottom: 0;
-  }
-
-  // Avi must go on top if the toot is too short
-  .status__avatar {
-    z-index: 10;
-  }
-
-  // Base styles
-  .status__content--with-action > div::after {
-    content: '';
-    display: block;
-    width: 64px;
-    position: absolute;
-    left: -68px;
-
-    // more than 4 never fit on FullHD, short
-    @include xtraspaces-short;
-  }
-
-  @media screen and (min-width: 1800px) {
-    // 4, very wide screen
-    .column:nth-child(2):nth-last-child(4) {
-      &, & ~ .column {
-        .status__content--with-action > div::after {
-          @include xtraspaces-full;
-        }
-      }
-    }
-  }
-
-  // 1 or 2, always fit
-  .column:nth-child(2):nth-last-child(1),
-  .column:nth-child(2):nth-last-child(2),
-  .column:nth-child(2):nth-last-child(3) {
-    &, & ~ .column {
-      .status__content--with-action > div::after {
-        @include xtraspaces-full;
-      }
-    }
-  }
-
-  @media screen and (max-width: 1440px) {
-    // 3, small screen
-    .column:nth-child(2):nth-last-child(3) {
-      &, & ~ .column {
-        .status__content--with-action > div::after {
-          @include xtraspaces-short;
-        }
-      }
-    }
-  }
-
-  // Phone or iPad
-  @media screen and (max-width: 1060px) {
-    .status__content--with-action > div::after {
-      display: none;
-    }
-  }
-
-  // I am very sorry
-}
-// --- end extra clickable spaces ---
-
 .status-check-box {
   .status__content,
   .reply-indicator__content {
@@ -543,6 +502,8 @@
 .status__content,
 .reply-indicator__content {
   position: relative;
+  margin: 10px 0;
+  padding: 0 12px;
   font-size: 15px;
   line-height: 20px;
   color: $primary-text-color;
@@ -630,8 +591,10 @@
 }
 
 .status__prepend-icon-wrapper {
-  left: -26px;
-  position: absolute;
+  float: left;
+  margin: 0 10px 0 -58px;
+  width: 48px;
+  text-align: right;
 }
 
 .notif-cleaning {
@@ -653,7 +616,6 @@
 
 .status {
   padding: 8px 10px;
-  padding-left: 68px;
   position: relative;
   height: auto;
   min-height: 48px;
@@ -668,6 +630,10 @@
   opacity: 1;
   animation: fade 150ms linear;
 
+  .video-player {
+    margin-top: 8px;
+  }
+
   &.status-direct {
     background: lighten($ui-base-color, 8%);
 
@@ -729,7 +695,7 @@
       content: "";
     }
 
-    .status__display-name:hover strong {
+    .display-name:hover .display-name__html {
       text-decoration: none;
     }
 
@@ -745,7 +711,7 @@
   }
 
   .notification__message {
-    margin: -10px 0 10px;
+    margin: -10px -10px 10px;
   }
 }
 
@@ -773,25 +739,21 @@
 }
 
 .status__display-name {
+  margin: 0 auto 0 0;
   color: $ui-base-lighter-color;
-}
-
-.status__info .status__display-name {
-  display: block;
-  max-width: 100%;
-  padding-right: 25px;
+  overflow: hidden;
 }
 
 .status__info {
-  margin: 2px 0 0;
+  display: flex;
+  margin: 2px 0 5px;
   font-size: 15px;
   line-height: 24px;
 }
 
 .status__info__icons {
-  display: inline-block;
+  flex: none;
   position: relative;
-  float: right;
   color: lighten($ui-base-color, 26%);
 
   .status__visibility-icon {
@@ -821,9 +783,9 @@
 }
 
 .status__prepend {
-  margin: -10px 0 10px;
+  margin: -10px -10px 10px;
   color: $ui-base-lighter-color;
-  padding: 8px 0 2px;
+  padding: 8px 10px 0 68px;
   font-size: 14px;
   position: relative;
 
@@ -835,15 +797,7 @@
 .status__action-bar {
   align-items: center;
   display: flex;
-  margin-top: 10px;
-  margin-left: -58px;
-
-  &::before {
-    display: block;
-    flex: 1 1 0;
-    max-width: 58px;
-    content: "";
-  }
+  margin: 10px 4px 0;
 }
 
 .status__action-bar-button {
@@ -854,8 +808,8 @@
 
 .status__action-bar-dropdown {
   float: left;
-  height: 18px;
-  width: 18px;
+  height: 23.15px;
+  width: 23.15px;
 
   // Dropdown style override for centering on the icon
   .dropdown--active {
@@ -881,26 +835,6 @@
   align-items: center;
   justify-content: center;
   position: relative;
-
-  .dropdown {
-    display: block;
-    width: 18px;
-    height: 18px;
-  }
-
-  .dropdown--active {
-    .dropdown__content.dropdown__left {
-      left: 20px;
-      right: initial;
-    }
-
-    &::after {
-      bottom: initial;
-      margin-left: 7px;
-      margin-top: -7px;
-      right: initial;
-    }
-  }
 }
 
 .detailed-status {
@@ -916,6 +850,10 @@
       height: 22px;
     }
   }
+
+  .video-player {
+    margin-top: 8px;
+  }
 }
 
 .detailed-status__meta {
@@ -976,8 +914,7 @@
 
 .account__avatar-wrapper {
   float: left;
-  margin-left: 12px;
-  margin-right: 12px;
+  margin: 6px 16px 6px 6px;
 }
 
 .account__avatar {
@@ -993,6 +930,7 @@
 }
 
 .account__avatar-overlay {
+  position: relative;
   @include avatar-size(48px);
 
   &-base {
@@ -1013,7 +951,8 @@
 
 .account__relationship {
   height: 18px;
-  padding: 10px;
+  padding: 12px 10px;
+  white-space: nowrap;
 }
 
 .account__header__wrapper {
@@ -1171,7 +1110,7 @@
 }
 
 .account__action-bar-dropdown {
-  flex: 1 1 auto;
+  flex: 0 1 calc(50% - 140px);
   padding: 10px;
 
   .dropdown--active {
@@ -1198,7 +1137,7 @@
 .account__action-bar__tab {
   text-decoration: none;
   overflow: hidden;
-  width: 80px;
+  flex: 0 1 80px;
   border-left: 1px solid lighten($ui-base-color, 8%);
   padding: 10px 5px;
 
@@ -1260,15 +1199,6 @@
   }
 }
 
-.status__display-name,
-.reply-indicator__display-name,
-.detailed-status__display-name,
-.account__display-name {
-  &:hover strong {
-    text-decoration: underline;
-  }
-}
-
 .account__display-name strong {
   display: block;
 }
@@ -1288,6 +1218,8 @@
   strong,
   span {
     display: block;
+    text-overflow: ellipsis;
+    overflow: hidden;
   }
 
   strong {
@@ -1302,8 +1234,8 @@
 }
 
 .status__avatar {
-  position: absolute;
-  margin-left: -58px;
+  flex: none;
+  margin: 0 10px 0 0;
   height: 48px;
   width: 48px;
 }
@@ -1334,9 +1266,7 @@
 }
 
 .notification__message {
-  margin-left: 68px;
-  padding: 8px 0;
-  padding-bottom: 0;
+  padding: 8px 10px 0 68px;
   cursor: default;
   color: $ui-primary-color;
   font-size: 15px;
@@ -1348,8 +1278,10 @@
 }
 
 .notification__favourite-icon-wrapper {
-  left: -26px;
-  position: absolute;
+  float: left;
+  margin: 0 10px 0 -58px;
+  width: 48px;
+  text-align: right;
 
   .star-icon {
     color: $gold-star;
@@ -1373,19 +1305,37 @@
 
 .display-name {
   display: block;
-  position: relative;
+  padding: 6px 0;
   max-width: 100%;
+  height: 36px;
   overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
 
-.display-name__html {
-  font-weight: 500;
-}
+  strong {
+    display: block;
+    height: 18px;
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 18px;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+  }
 
-.display-name__account {
-  font-size: 14px;
+  span {
+    display: block;
+    height: 18px;
+    font-size: 15px;
+    line-height: 18px;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+  }
+
+  &:hover {
+    strong {
+      text-decoration: underline;
+    }
+  }
 }
 
 .status__relative-time,
@@ -1474,10 +1424,80 @@
   position: absolute;
 }
 
-.dropdown__sep {
+.dropdown-menu__separator {
   border-bottom: 1px solid darken($ui-secondary-color, 8%);
   margin: 5px 7px 6px;
-  padding-top: 1px;
+  height: 0;
+}
+
+.dropdown-menu {
+  background: $ui-secondary-color;
+  padding: 4px 0;
+  border-radius: 4px;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
+
+  ul {
+    list-style: none;
+  }
+}
+
+.dropdown-menu__arrow {
+  position: absolute;
+  width: 0;
+  height: 0;
+  border: 0 solid transparent;
+
+  &.left {
+    right: -5px;
+    margin-top: -5px;
+    border-width: 5px 0 5px 5px;
+    border-left-color: $ui-secondary-color;
+  }
+
+  &.top {
+    bottom: -5px;
+    margin-left: -13px;
+    border-width: 5px 7px 0;
+    border-top-color: $ui-secondary-color;
+  }
+
+  &.bottom {
+    top: -5px;
+    margin-left: -13px;
+    border-width: 0 7px 5px;
+    border-bottom-color: $ui-secondary-color;
+  }
+
+  &.right {
+    left: -5px;
+    margin-top: -5px;
+    border-width: 5px 5px 5px 0;
+    border-right-color: $ui-secondary-color;
+  }
+}
+
+.dropdown-menu__item {
+  a {
+    font-size: 13px;
+    line-height: 18px;
+    display: block;
+    padding: 4px 14px;
+    box-sizing: border-box;
+    text-decoration: none;
+    background: $ui-secondary-color;
+    color: $ui-base-color;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:focus,
+    &:hover,
+    &:active {
+      background: $ui-highlight-color;
+      color: $ui-secondary-color;
+      outline: 0;
+    }
+  }
 }
 
 .dropdown--active .dropdown__content {
@@ -1647,9 +1667,8 @@
 
 .column,
 .drawer {
-  @supports(display: grid) { // hack to fix Chrome <57
-    contain: strict;
-  }
+  flex: 1 1 100%;
+  overflow: hidden;
 }
 
 @include limited-single-column('screen and (max-width: 360px)', $parent: null) {
@@ -1663,7 +1682,7 @@
 }
 
 :root {  //  Overrides .wide stylings for mobile view
-  @include single-column('screen and (max-width: 1024px)', $parent: null) {
+  @include single-column('screen and (max-width: 630px)', $parent: null) {
     .column,
     .drawer {
       flex: auto;
@@ -1684,7 +1703,7 @@
   }
 }
 
-@include multi-columns('screen and (min-width: 1025px)', $parent: null) {
+@include multi-columns('screen and (min-width: 631px)', $parent: null) {
   .columns-area {
     padding: 0;
   }
@@ -1796,7 +1815,7 @@
   &:hover,
   &:focus,
   &:active {
-    @include multi-columns('screen and (min-width: 1025px)') {
+    @include multi-columns('screen and (min-width: 631px)') {
       background: lighten($ui-base-color, 14%);
       transition: all 100ms linear;
     }
@@ -1816,7 +1835,7 @@
   }
 }
 
-@include multi-columns('screen and (min-width: 1025px)', $parent: null) {
+@include multi-columns('screen and (min-width: 631px)', $parent: null) {
   .tabs-bar {
     display: none;
   }
@@ -1826,11 +1845,8 @@
   overflow-y: scroll;
   overflow-x: hidden;
   flex: 1 1 auto;
-  backface-visibility: hidden;
   -webkit-overflow-scrolling: touch;
-  @supports(display: grid) { // hack to fix Chrome <57
-    contain: strict;
-  }
+  will-change: transform; // improves perf in mobile Chrome
 
   &.optionally-scrollable {
     overflow-y: auto;
@@ -1844,8 +1860,9 @@
   flex: 0 0 auto;
   font-size: 16px;
   border: 0;
-  text-align: start;
+  text-align: unset;
   padding: 15px;
+  margin: 0;
   z-index: 3;
 
   &:hover {
@@ -1867,6 +1884,10 @@
   &:hover {
     text-decoration: underline;
   }
+
+  &:last-child {
+    padding: 0 15px 0 0;
+  }
 }
 
 .column-back-button__icon {
@@ -2071,15 +2092,18 @@
 }
 
 .autosuggest-textarea__suggestions {
+  box-sizing: border-box;
   display: none;
   position: absolute;
   top: 100%;
   width: 100%;
   z-index: 99;
-  box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
+  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
   background: $ui-secondary-color;
+  border-radius: 0 0 4px 4px;
   color: $ui-base-color;
   font-size: 14px;
+  padding: 6px;
 
   &.autosuggest-textarea__suggestions--visible {
     display: block;
@@ -2089,39 +2113,41 @@
 .autosuggest-textarea__suggestions__item {
   padding: 10px;
   cursor: pointer;
+  border-radius: 4px;
 
-  &:hover {
-    background: darken($ui-secondary-color, 10%);
-  }
-
+  &:hover,
+  &:focus,
+  &:active,
   &.selected {
-    background: $ui-highlight-color;
-    color: $base-border-color;
+    background: darken($ui-secondary-color, 10%);
   }
 }
 
-.autosuggest-account {
-  overflow: hidden;
+.autosuggest-account,
+.autosuggest-emoji {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-start;
+  line-height: 18px;
+  font-size: 14px;
 }
 
-.autosuggest-account-icon {
-  float: left;
-  margin-right: 5px;
+.autosuggest-account-icon,
+.autosuggest-emoji img {
+  display: block;
+  margin-right: 8px;
+  width: 16px;
+  height: 16px;
 }
 
-.autosuggest-status {
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-
-  strong {
-    font-weight: 500;
-  }
+.autosuggest-account .display-name__account {
+  color: lighten($ui-base-color, 36%);
 }
 
 .character-counter__wrapper {
   line-height: 36px;
-  margin-right: 16px;
+  margin: 0 16px 0 8px;
   padding-top: 10px;
 }
 
@@ -2304,6 +2330,18 @@ button.icon-button.active i.fa-retweet {
   background: lighten($ui-base-color, 8%);
 }
 
+.status-card.horizontal {
+  display: block;
+
+  .status-card__image {
+    width: 100%;
+  }
+
+  .status-card__image-image {
+    border-radius: 4px 4px 0 0;
+  }
+}
+
 .status-card__image-image {
   border-radius: 4px 0 0 4px;
   display: block;
@@ -2662,12 +2700,8 @@ button.icon-button.active i.fa-retweet {
 }
 
 .media-spoiler {
-  align-items: center;
   background: $base-overlay-background;
-  color: $primary-text-color;
-  cursor: pointer;
-  display: flex;
-  flex-direction: column;
+  color: $ui-primary-color;
   border: 0;
   width: 100%;
   height: 100%;
@@ -2675,15 +2709,14 @@ button.icon-button.active i.fa-retweet {
   position: relative;
   text-align: center;
   z-index: 100;
+  display: flex;
+  flex-direction: column;
 
   .status__content > & {
     margin-top: 15px; // Add margin when used bare for NSFW video player
   }
 
-  &.full-width {
-    margin-left: -68px;
-    width: calc(100% + 80px);
-  }
+  @include fullwidth-gallery;
 }
 
 .media-spoiler__warning {
@@ -2858,197 +2891,61 @@ button.icon-button.active i.fa-retweet {
   animation-direction: alternate;
 }
 
-.emoji-dialog {
-  width: 245px;
-  height: 270px;
+.emoji-picker-dropdown__menu {
   background: $simple-background-color;
-  box-sizing: border-box;
+  position: absolute;
+  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
   border-radius: 4px;
-  overflow: hidden;
-  position: relative;
-  box-shadow: 0 0 8px rgba($base-shadow-color, 0.2);
-
-  .emojione {
-    margin: 0;
-    width: 100%;
-    height: auto;
-  }
-
-  .emoji-dialog-header {
-    padding: 0 10px;
-
-    ul {
-      padding: 0;
-      margin: 0;
-      list-style: none;
-    }
-
-    li {
-      display: inline-block;
-      box-sizing: border-box;
-      padding: 10px 5px;
-      cursor: pointer;
-      border-bottom: 2px solid transparent;
-
-      .emoji {
-        width: 18px;
-        height: 18px;
-      }
-
-      img,
-      svg {
-        width: 18px;
-        height: 18px;
-        filter: grayscale(100%);
-      }
-
-      &:hover {
-        img,
-        svg {
-          filter: grayscale(0);
-        }
-      }
-
-      &.active {
-        border-bottom-color: $ui-highlight-color;
-
-        img,
-        svg {
-          filter: grayscale(0);
-        }
-      }
-    }
-  }
-
-  .emoji-row {
-    box-sizing: border-box;
-    overflow-y: hidden;
-    padding-left: 10px;
-
-    .emoji {
-      display: inline-block;
-      padding: 2.5px;
-      border-radius: 4px;
-    }
-  }
-
-  .emoji-category-header {
-    box-sizing: border-box;
-    overflow-y: hidden;
-    padding: 10px 8px 10px 16px;
-    display: table;
-
-    > * {
-      display: table-cell;
-      vertical-align: middle;
-    }
-  }
+  margin-top: 5px;
 
-  .emoji-category-title {
-    font-size: 12px;
-    text-transform: uppercase;
-    font-weight: 500;
-    color: darken($ui-secondary-color, 18%);
-    cursor: default;
+  .emoji-mart-scroll {
+    transition: opacity 200ms ease;
   }
 
-  .emoji-category-heading-decoration {
-    text-align: right;
+  &.selecting .emoji-mart-scroll {
+    opacity: 0.5;
   }
+}
 
-  .modifiers {
-    list-style: none;
-    padding: 0;
-    margin: 0;
-    vertical-align: middle;
-    white-space: nowrap;
-    margin-top: 4px;
-
-    li {
-      display: inline-block;
-      padding: 0 2px;
-
-      &:last-of-type {
-        padding-right: 0;
-      }
-    }
-
-    .modifier {
-      display: inline-block;
-      border-radius: 10px;
-      width: 15px;
-      height: 15px;
-      position: relative;
-      cursor: pointer;
-
-      &.active::after {
-        content: "";
-        display: block;
-        position: absolute;
-        width: 7px;
-        height: 7px;
-        border-radius: 10px;
-        border: 2px solid $base-border-color;
-        top: 2px;
-        left: 2px;
-      }
-    }
-  }
+.emoji-picker-dropdown__modifiers {
+  position: absolute;
+  top: 60px;
+  right: 11px;
+  cursor: pointer;
+}
 
-  .emoji-search-wrapper {
-    padding: 10px;
-    border-bottom: 1px solid lighten($ui-secondary-color, 4%);
-  }
+.emoji-picker-dropdown__modifiers__menu {
+  position: absolute;
+  z-index: 4;
+  top: -4px;
+  left: -8px;
+  background: $simple-background-color;
+  border-radius: 4px;
+  box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+  overflow: hidden;
 
-  .emoji-search {
-    font-size: 14px;
-    font-weight: 400;
-    padding: 7px 9px;
-    font-family: inherit;
+  button {
     display: block;
-    width: 100%;
-    background: rgba($ui-secondary-color, 0.3);
-    color: darken($ui-secondary-color, 18%);
-    border: 1px solid $ui-secondary-color;
-    border-radius: 4px;
-  }
-
-  .emoji-categories-wrapper {
-    position: absolute;
-    top: 42px;
-    bottom: 0;
-    left: 0;
-    right: 0;
-  }
-
-  .emoji-search-wrapper + .emoji-categories-wrapper {
-    top: 93px;
-  }
-
-  .emoji-row .emoji {
-    img,
-    svg {
-      transition: transform 60ms ease-in-out;
-    }
-
-    &:hover {
-      background: lighten($ui-secondary-color, 3%);
+    cursor: pointer;
+    border: 0;
+    padding: 4px 8px;
+    background: transparent;
 
-      img,
-      svg {
-        transform: translateZ(0) scale(1.2);
-      }
+    &:hover,
+    &:focus,
+    &:active {
+      background: rgba($ui-secondary-color, 0.4);
     }
   }
 
-  .emoji {
-    width: 22px;
+  .emoji-mart-emoji {
     height: 22px;
-    cursor: pointer;
+  }
+}
 
-    &:focus {
-      outline: 0;
-    }
+.emoji-mart-emoji {
+  span {
+    background-repeat: no-repeat;
   }
 }
 
@@ -3335,8 +3232,6 @@ button.icon-button.active i.fa-retweet {
 }
 
 .search__input {
-  padding-right: 30px;
-  color: $ui-secondary-color;
   outline: 0;
   box-sizing: border-box;
   display: block;
@@ -3524,7 +3419,8 @@ button.icon-button.active i.fa-retweet {
 }
 
 .onboarding-modal,
-.error-modal {
+.error-modal,
+.embed-modal {
   background: $ui-secondary-color;
   color: $ui-base-color;
   border-radius: 8px;
@@ -3848,7 +3744,8 @@ button.icon-button.active i.fa-retweet {
 .boost-modal,
 .confirmation-modal,
 .report-modal,
-.actions-modal {
+.actions-modal,
+.mute-modal {
   background: lighten($ui-secondary-color, 8%);
   color: $ui-base-color;
   border-radius: 8px;
@@ -3859,17 +3756,7 @@ button.icon-button.active i.fa-retweet {
   flex-direction: column;
 
   .status__display-name {
-    display: block;
-    max-width: 100%;
-    padding-right: 25px;
-  }
-
-  .status__avatar {
-    height: 28px;
-    left: 10px;
-    position: absolute;
-    top: 10px;
-    width: 48px;
+    display: flex;
   }
 }
 
@@ -3880,6 +3767,10 @@ button.icon-button.active i.fa-retweet {
     padding-top: 10px;
     padding-bottom: 10px;
   }
+
+  .dropdown-menu__separator {
+    border-bottom-color: $ui-secondary-color;
+  }
 }
 
 .boost-modal__container {
@@ -3894,6 +3785,7 @@ button.icon-button.active i.fa-retweet {
 
 .boost-modal__action-bar,
 .confirmation-modal__action-bar,
+.mute-modal__action-bar,
 .report-modal__action-bar {
   display: flex;
   justify-content: space-between;
@@ -3957,6 +3849,10 @@ button.icon-button.active i.fa-retweet {
   max-height: 80vh;
   max-width: 80vw;
 
+  .actions-modal__item-label {
+    font-weight: 500;
+  }
+
   ul {
     overflow-y: auto;
     flex-shrink: 0;
@@ -3969,11 +3865,20 @@ button.icon-button.active i.fa-retweet {
       a {
         color: $ui-base-color;
         display: flex;
-        padding: 10px;
+        padding: 12px 16px;
+        font-size: 15px;
         align-items: center;
         text-decoration: none;
 
-        &.active {
+        &,
+        button {
+          transition: none;
+        }
+
+        &.active,
+        &:hover,
+        &:active,
+        &:focus {
           &,
           button {
             background: $ui-highlight-color;
@@ -3989,8 +3894,10 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
-.confirmation-modal__action-bar {
-  .confirmation-modal__cancel-button {
+.confirmation-modal__action-bar,
+.mute-modal__action-bar {
+  .confirmation-modal__cancel-button,
+  .mute-modal__cancel-button {
     background-color: transparent;
     color: darken($ui-secondary-color, 34%);
     font-size: 14px;
@@ -4005,6 +3912,7 @@ button.icon-button.active i.fa-retweet {
 }
 
 .confirmation-modal__container,
+.mute-modal__container,
 .report-modal__target {
   padding: 30px;
   font-size: 16px;
@@ -4113,15 +4021,12 @@ button.icon-button.active i.fa-retweet {
   background: $base-shadow-color;
   width: 100%;
 
-  &.full-width {
-    margin-left: -68px;
-    width: calc(100% + 80px);
-  }
-
   .detailed-status & {
     margin-left:-10px;
     width: calc(100% + 22px);
   }
+
+  @include fullwidth-gallery;
 }
 
 .media-gallery__item {
@@ -4130,6 +4035,12 @@ button.icon-button.active i.fa-retweet {
   display: block;
   float: left;
   position: relative;
+
+  &.standalone {
+    .media-gallery__item-gifv-thumbnail {
+      transform: none;
+    }
+  }
 }
 
 .media-gallery__item-thumbnail {
@@ -4137,6 +4048,7 @@ button.icon-button.active i.fa-retweet {
   text-decoration: none;
   width: 100%;
   height: 100%;
+  line-height: 0;
   display: flex;
 
   img {
@@ -4192,10 +4104,7 @@ button.icon-button.active i.fa-retweet {
   position: relative;
   width: 100%;
 
-  &.full-width {
-    margin-left: -68px;
-    width: calc(100% + 80px);
-  }
+  @include fullwidth-gallery;
 }
 
 .status__video-player-video {
@@ -4242,6 +4151,182 @@ button.icon-button.active i.fa-retweet {
   z-index: 5;
 }
 
+.video-player {
+  overflow: hidden;
+  position: relative;
+  background: $base-shadow-color;
+  max-width: 100%;
+
+  video {
+    height: 100%;
+    width: 100%;
+    z-index: 1;
+  }
+
+  &.fullscreen {
+    width: 100% !important;
+    height: 100% !important;
+    margin: 0;
+
+    video {
+      max-width: 100% !important;
+      max-height: 100% !important;
+    }
+  }
+
+  &.inline {
+    video {
+      object-fit: cover;
+      position: relative;
+      top: 50%;
+      transform: translateY(-50%);
+    }
+  }
+
+  &__controls {
+    position: absolute;
+    z-index: 2;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    box-sizing: border-box;
+    background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 60%, transparent);
+    padding: 0 10px;
+    opacity: 0;
+    transition: opacity .1s ease;
+
+    &.active {
+      opacity: 1;
+    }
+  }
+
+  &.inactive {
+    video,
+    .video-player__controls {
+      visibility: hidden;
+    }
+  }
+
+  &__spoiler {
+    display: none;
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 4;
+    border: 0;
+    background: $base-shadow-color;
+    color: $ui-primary-color;
+    transition: none;
+    pointer-events: none;
+
+    &.active {
+      display: block;
+      pointer-events: auto;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($ui-primary-color, 8%);
+      }
+    }
+
+    &__title {
+      display: block;
+      font-size: 14px;
+    }
+
+    &__subtitle {
+      display: block;
+      font-size: 11px;
+      font-weight: 500;
+    }
+  }
+
+  &__buttons {
+    padding-bottom: 10px;
+    font-size: 16px;
+
+    &.left {
+      float: left;
+
+      button {
+        padding-right: 10px;
+      }
+    }
+
+    &.right {
+      float: right;
+
+      button {
+        padding-left: 10px;
+      }
+    }
+
+    button {
+      background: transparent;
+      padding: 0;
+      border: 0;
+      color: $white;
+
+      &:active,
+      &:hover,
+      &:focus {
+        color: $ui-highlight-color;
+      }
+    }
+  }
+
+  &__seek {
+    cursor: pointer;
+    height: 24px;
+    position: relative;
+
+    &::before {
+      content: "";
+      width: 100%;
+      background: rgba($white, 0.35);
+      display: block;
+      position: absolute;
+      height: 4px;
+      top: 10px;
+    }
+
+    &__progress {
+      display: block;
+      position: absolute;
+      height: 4px;
+      top: 10px;
+      background: $ui-highlight-color;
+    }
+
+    &__handle {
+      position: absolute;
+      z-index: 3;
+      opacity: 0;
+      border-radius: 50%;
+      width: 12px;
+      height: 12px;
+      top: 6px;
+      margin-left: -6px;
+      transition: opacity .1s ease;
+      background: $ui-highlight-color;
+      pointer-events: none;
+
+      &.active {
+        opacity: 1;
+      }
+    }
+
+    &:hover {
+      .video-player__seek__handle {
+        opacity: 1;
+      }
+    }
+  }
+}
+
 .media-spoiler-video {
   background-size: cover;
   background-repeat: no-repeat;
@@ -4251,10 +4336,10 @@ button.icon-button.active i.fa-retweet {
   position: relative;
   width: 100%;
 
-  &.full-width {
-    margin-left: -68px;
-    width: calc(100% + 80px);
-  }
+  @include fullwidth-gallery;
+
+  border: 0;
+  display: block;
 }
 
 .media-spoiler-video-play-icon {
@@ -4272,12 +4357,14 @@ button.icon-button.active i.fa-retweet {
 .account-gallery__container {
   margin: -2px;
   padding: 4px;
+  display: flex;
+  flex-wrap: wrap;
 }
 
 .account-gallery__item {
-  float: left;
-  width: 96px;
-  height: 96px;
+  flex: 1 1 auto;
+  width: calc(100% / 3 - 4px);
+  height: 95px;
   margin: 2px;
 
   a {
@@ -4288,6 +4375,14 @@ button.icon-button.active i.fa-retweet {
     background-size: cover;
     background-position: center;
     position: relative;
+    color: inherit;
+    text-decoration: none;
+
+    &:hover,
+    &:active,
+    &:focus {
+      outline: 0;
+    }
   }
 }
 
@@ -4339,6 +4434,15 @@ noscript {
     margin: 30px auto;
     color: $ui-secondary-color;
     max-width: 400px;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+
+      &:hover {
+        text-decoration: none;
+      }
+    }
   }
 }
 
@@ -4348,7 +4452,7 @@ noscript {
   100% { opacity: 1; }
 }
 
-@media screen and (max-width: 1024px) and (max-height: 400px) {
+@media screen and (max-width: 630px) and (max-height: 400px) {
   $duration: 400ms;
   $delay: 100ms;
 
@@ -4446,3 +4550,64 @@ noscript {
     height: 100% !important;
   }
 }
+
+.embed-modal {
+  max-width: 80vw;
+  max-height: 80vh;
+
+  h4 {
+    padding: 30px;
+    font-weight: 500;
+    font-size: 16px;
+    text-align: center;
+  }
+
+  .embed-modal__container {
+    padding: 10px;
+
+    .hint {
+      margin-bottom: 15px;
+    }
+
+    .embed-modal__html {
+      color: $ui-secondary-color;
+      outline: 0;
+      box-sizing: border-box;
+      display: block;
+      width: 100%;
+      border: none;
+      padding: 10px;
+      font-family: 'mastodon-font-monospace', monospace;
+      background: $ui-base-color;
+      color: $ui-primary-color;
+      font-size: 14px;
+      margin: 0;
+      margin-bottom: 15px;
+
+      &::-moz-focus-inner {
+        border: 0;
+      }
+
+      &::-moz-focus-inner,
+      &:focus,
+      &:active {
+        outline: 0 !important;
+      }
+
+      &:focus {
+        background: lighten($ui-base-color, 4%);
+      }
+
+      @media screen and (max-width: 600px) {
+        font-size: 16px;
+      }
+    }
+
+    .embed-modal__iframe {
+      width: 400px;
+      max-width: 100%;
+      overflow: hidden;
+      border: 0;
+    }
+  }
+}
diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss
index 7dcf2c006..af2589e23 100644
--- a/app/javascript/styles/containers.scss
+++ b/app/javascript/styles/containers.scss
@@ -3,7 +3,7 @@
   margin: 0 auto;
   margin-top: 40px;
 
-  @media screen and (max-width: 700px) {
+  @media screen and (max-width: 740px) {
     width: 100%;
     margin: 0;
   }
@@ -13,8 +13,9 @@
   margin: 100px auto;
   margin-bottom: 50px;
 
-  @media screen and (max-width: 360px) {
+  @media screen and (max-width: 400px) {
     margin: 30px auto;
+    margin-bottom: 20px;
   }
 
   h1 {
@@ -42,3 +43,74 @@
     }
   }
 }
+
+.compose-standalone {
+  .compose-form {
+    width: 400px;
+    margin: 0 auto;
+    padding: 20px 0;
+    margin-top: 40px;
+    box-sizing: border-box;
+
+    @media screen and (max-width: 400px) {
+      width: 100%;
+      margin-top: 0;
+      padding: 20px;
+    }
+  }
+}
+
+.account-header {
+  width: 400px;
+  margin: 0 auto;
+  display: flex;
+  font-size: 13px;
+  line-height: 18px;
+  box-sizing: border-box;
+  padding: 20px 0;
+  padding-bottom: 0;
+  margin-bottom: -30px;
+  margin-top: 40px;
+
+  @media screen and (max-width: 440px) {
+    width: 100%;
+    margin: 0;
+    margin-bottom: 10px;
+    padding: 20px;
+    padding-bottom: 0;
+  }
+
+  .avatar {
+    width: 40px;
+    height: 40px;
+    margin-right: 8px;
+
+    img {
+      width: 100%;
+      height: 100%;
+      display: block;
+      margin: 0;
+      border-radius: 4px;
+    }
+  }
+
+  .name {
+    flex: 1 1 auto;
+    color: $ui-secondary-color;
+    width: calc(100% - 88px);
+
+    .username {
+      display: block;
+      font-weight: 500;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+  }
+
+  .logout-link {
+    display: block;
+    font-size: 32px;
+    line-height: 40px;
+    margin-left: 8px;
+  }
+}
diff --git a/app/javascript/styles/custom.scss b/app/javascript/styles/custom.scss
deleted file mode 100644
index 97a981243..000000000
--- a/app/javascript/styles/custom.scss
+++ /dev/null
@@ -1 +0,0 @@
-@import 'application';
diff --git a/app/javascript/styles/emoji_picker.scss b/app/javascript/styles/emoji_picker.scss
new file mode 100644
index 000000000..2b46d30fc
--- /dev/null
+++ b/app/javascript/styles/emoji_picker.scss
@@ -0,0 +1,199 @@
+.emoji-mart {
+  &,
+  * {
+    box-sizing: border-box;
+    line-height: 1.15;
+  }
+
+  font-size: 13px;
+  display: inline-block;
+  color: $ui-base-color;
+
+  .emoji-mart-emoji {
+    padding: 6px;
+  }
+}
+
+.emoji-mart-bar {
+  border: 0 solid darken($ui-secondary-color, 8%);
+
+  &:first-child {
+    border-bottom-width: 1px;
+    border-top-left-radius: 5px;
+    border-top-right-radius: 5px;
+    background: $ui-secondary-color;
+  }
+
+  &:last-child {
+    border-top-width: 1px;
+    border-bottom-left-radius: 5px;
+    border-bottom-right-radius: 5px;
+    display: none;
+  }
+}
+
+.emoji-mart-anchors {
+  display: flex;
+  justify-content: space-between;
+  padding: 0 6px;
+  color: $ui-primary-color;
+  line-height: 0;
+}
+
+.emoji-mart-anchor {
+  position: relative;
+  flex: 1;
+  text-align: center;
+  padding: 12px 4px;
+  overflow: hidden;
+  transition: color .1s ease-out;
+  cursor: pointer;
+
+  &:hover {
+    color: darken($ui-primary-color, 4%);
+  }
+}
+
+.emoji-mart-anchor-selected {
+  color: darken($ui-highlight-color, 3%);
+
+  &:hover {
+    color: darken($ui-highlight-color, 3%);
+  }
+
+  .emoji-mart-anchor-bar {
+    bottom: 0;
+  }
+}
+
+.emoji-mart-anchor-bar {
+  position: absolute;
+  bottom: -3px;
+  left: 0;
+  width: 100%;
+  height: 3px;
+  background-color: darken($ui-highlight-color, 3%);
+}
+
+.emoji-mart-anchors {
+  i {
+    display: inline-block;
+    width: 100%;
+    max-width: 22px;
+  }
+
+  svg {
+    fill: currentColor;
+    max-height: 18px;
+  }
+}
+
+.emoji-mart-scroll {
+  overflow-y: scroll;
+  height: 270px;
+  max-height: 35vh;
+  padding: 0 6px 6px;
+  background: $simple-background-color;
+  will-change: transform;
+}
+
+.emoji-mart-search {
+  padding: 10px;
+  padding-right: 45px;
+  background: $simple-background-color;
+
+  input {
+    font-size: 14px;
+    font-weight: 400;
+    padding: 7px 9px;
+    font-family: inherit;
+    display: block;
+    width: 100%;
+    background: rgba($ui-secondary-color, 0.3);
+    color: $ui-primary-color;
+    border: 1px solid $ui-secondary-color;
+    border-radius: 4px;
+
+    &::-moz-focus-inner {
+      border: 0;
+    }
+
+    &::-moz-focus-inner,
+    &:focus,
+    &:active {
+      outline: 0 !important;
+    }
+  }
+}
+
+.emoji-mart-category .emoji-mart-emoji {
+  cursor: pointer;
+
+  span {
+    z-index: 1;
+    position: relative;
+    text-align: center;
+  }
+
+  &:hover::before {
+    z-index: 0;
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: rgba($ui-secondary-color, 0.7);
+    border-radius: 100%;
+  }
+}
+
+.emoji-mart-category-label {
+  z-index: 2;
+  position: relative;
+  position: -webkit-sticky;
+  position: sticky;
+  top: 0;
+
+  span {
+    display: block;
+    width: 100%;
+    font-weight: 500;
+    padding: 5px 6px;
+    background: $simple-background-color;
+  }
+}
+
+.emoji-mart-emoji {
+  position: relative;
+  display: inline-block;
+  font-size: 0;
+
+  span {
+    width: 22px;
+    height: 22px;
+  }
+}
+
+.emoji-mart-no-results {
+  font-size: 14px;
+  text-align: center;
+  padding-top: 70px;
+  color: $ui-primary-color;
+
+  .emoji-mart-category-label {
+    display: none;
+  }
+
+  .emoji-mart-no-results-label {
+    margin-top: .2em;
+  }
+
+  .emoji-mart-emoji:hover::before {
+    content: none;
+  }
+}
+
+.emoji-mart-preview {
+  display: none;
+}
diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss
index cffb6f197..0526f174c 100644
--- a/app/javascript/styles/forms.scss
+++ b/app/javascript/styles/forms.scss
@@ -24,7 +24,7 @@ code {
 
   p.hint {
     margin-bottom: 15px;
-    color: lighten($ui-base-color, 32%);
+    color: $ui-primary-color;
 
     &.subtle-hint {
       text-align: center;
@@ -32,10 +32,10 @@ code {
       line-height: 18px;
       margin-top: 15px;
       margin-bottom: 0;
-      color: $ui-base-lighter-color;
+      color: $ui-primary-color;
 
       a {
-        color: $ui-primary-color;
+        color: $ui-highlight-color;
       }
     }
   }
@@ -53,7 +53,6 @@ code {
 
     label {
       flex: 0 0 auto;
-      width: 100px;
     }
 
     input {
@@ -65,12 +64,37 @@ code {
     padding: 15px 0;
     margin-bottom: 0;
 
+    .label_input {
+      flex-wrap: wrap;
+      align-items: flex-start;
+    }
+
+    &.select .label_input {
+      align-items: initial;
+    }
+
     .label_input > label {
       font-family: inherit;
       font-size: 16px;
       color: $primary-text-color;
       display: block;
       padding-top: 5px;
+      margin-bottom: 5px;
+      flex: 1;
+      min-width: 150px;
+      word-wrap: break-word;
+
+      &.select {
+        flex: 0;
+      }
+
+      & ~ * {
+        margin-left: 10px;
+      }
+    }
+
+    ul {
+      flex: 390px;
     }
 
     &.boolean {
@@ -317,7 +341,7 @@ code {
 }
 
 .flash-message {
-  background: $ui-base-color;
+  background: lighten($ui-base-color, 8%);
   color: $ui-primary-color;
   border-radius: 4px;
   padding: 15px 10px;
@@ -325,9 +349,46 @@ code {
   box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
   text-align: center;
 
+  p {
+    margin-bottom: 15px;
+  }
+
+  .oauth-code {
+    color: $ui-secondary-color;
+    outline: 0;
+    box-sizing: border-box;
+    display: block;
+    width: 100%;
+    border: none;
+    padding: 10px;
+    font-family: 'mastodon-font-monospace', monospace;
+    background: $ui-base-color;
+    color: $ui-primary-color;
+    font-size: 14px;
+    margin: 0;
+
+    &::-moz-focus-inner {
+      border: 0;
+    }
+
+    &::-moz-focus-inner,
+    &:focus,
+    &:active {
+      outline: 0 !important;
+    }
+
+    &:focus {
+      background: lighten($ui-base-color, 4%);
+    }
+  }
+
   strong {
     font-weight: 500;
   }
+
+  @media screen and (max-width: 740px) and (min-width: 441px) {
+    margin-top: 40px;
+  }
 }
 
 .form-footer {
@@ -359,17 +420,23 @@ code {
     color: $ui-secondary-color;
     font-weight: 500;
   }
+
+  @media screen and (max-width: 740px) and (min-width: 441px) {
+    margin-top: 40px;
+  }
 }
 
 .qr-wrapper {
   display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
 }
 
 .qr-code {
   flex: 0 0 auto;
   background: $simple-background-color;
   padding: 4px;
-  margin-bottom: 20px;
+  margin: 0 10px 20px 0;
   box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
   display: inline-block;
 
@@ -380,8 +447,9 @@ code {
 }
 
 .qr-alternative {
-  margin-left: 10px;
-  color: $ui-primary-color;
+  margin-bottom: 20px;
+  color: $ui-secondary-color;
+  flex: 150px;
 
   samp {
     display: block;
@@ -391,7 +459,6 @@ code {
 
 .table-form {
   p {
-    max-width: 400px;
     margin-bottom: 15px;
 
     strong {
@@ -403,7 +470,6 @@ code {
 .simple_form,
 .table-form {
   .warning {
-    max-width: 400px;
     box-sizing: border-box;
     background: rgba($error-value-color, 0.5);
     color: $primary-text-color;
diff --git a/app/javascript/styles/landing_strip.scss b/app/javascript/styles/landing_strip.scss
index d2ac5b822..15ff84912 100644
--- a/app/javascript/styles/landing_strip.scss
+++ b/app/javascript/styles/landing_strip.scss
@@ -5,6 +5,8 @@
   padding: 14px;
   border-radius: 4px;
   margin-bottom: 20px;
+  display: flex;
+  align-items: center;
 
   strong,
   a {
@@ -15,4 +17,15 @@
     color: inherit;
     text-decoration: underline;
   }
+
+  .logo {
+    width: 30px;
+    height: 30px;
+    flex: 0 0 auto;
+    margin-right: 15px;
+  }
+
+  @media screen and (max-width: 740px) {
+    margin-bottom: 0;
+  }
 }
diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss
index 4966fbc21..0fdeccd9c 100644
--- a/app/javascript/styles/rtl.scss
+++ b/app/javascript/styles/rtl.scss
@@ -8,7 +8,7 @@ body.rtl {
   }
 
   .character-counter__wrapper {
-    margin-right: 0;
+    margin-right: 8px;
     margin-left: 16px;
   }
 
@@ -32,6 +32,11 @@ body.rtl {
     right: auto;
   }
 
+  .column-header__back-button {
+    padding-left: 5px;
+    padding-right: 0;
+  }
+
   .column-header__setting-arrows {
     float: left;
   }
@@ -54,25 +59,64 @@ body.rtl {
     right: 10px;
   }
 
-  .status {
+  .status,
+  .activity-stream .status.light {
     padding-left: 10px;
     padding-right: 68px;
   }
 
-  .status__info .status__display-name {
+  .status__info .status__display-name,
+  .activity-stream .status.light .status__display-name {
     padding-left: 25px;
     padding-right: 0;
   }
 
+  .activity-stream .pre-header {
+    padding-right: 68px;
+    padding-left: 0;
+  }
+
+  .status__prepend {
+    margin-left: 0;
+    margin-right: 68px;
+  }
+
+  .status__prepend-icon-wrapper {
+    left: auto;
+    right: -26px;
+  }
+
+  .activity-stream .pre-header .pre-header__icon {
+    left: auto;
+    right: 42px;
+  }
+
+  .account__avatar-overlay-overlay {
+    right: auto;
+    left: 0;
+  }
+
   .column-back-button--slim-button {
     right: auto;
     left: 0;
   }
 
-  .status__relative-time {
+  .status__relative-time,
+  .activity-stream .status.light .status__header .status__meta {
     float: left;
   }
 
+  .activity-stream .detailed-status.light .detailed-status__display-name > div {
+    float: right;
+    margin-right: 0;
+    margin-left: 10px;
+  }
+
+  .activity-stream .detailed-status.light .detailed-status__meta span > span {
+    margin-left: 0;
+    margin-right: 6px;
+  }
+
   .status__action-bar-button {
     float: right;
     margin-right: 0;
@@ -129,7 +173,79 @@ body.rtl {
     right: -2.14285714em;
   }
 
-  @media screen and (min-width: 1025px) {
+  .admin-wrapper .sidebar ul a i.fa,
+  a.table-action-link i.fa {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .simple_form .check_boxes .checkbox label,
+  .simple_form .input.with_label.boolean label.checkbox {
+    padding-left: 0;
+    padding-right: 25px;
+  }
+
+  .simple_form .check_boxes .checkbox input[type="checkbox"],
+  .simple_form .input.boolean input[type="checkbox"] {
+    left: auto;
+    right: 0;
+  }
+
+  .simple_form .input-with-append .input input {
+    padding-left: 127px;
+    padding-right: 0;
+  }
+
+  .simple_form .input-with-append .append {
+    right: auto;
+    left: 0;
+  }
+
+  .table th,
+  .table td {
+    text-align: right;
+  }
+
+  .filters .filter-subset {
+    margin-right: 0;
+    margin-left: 45px;
+  }
+
+  .landing-page .header-wrapper .mascot {
+    right: 60px;
+    left: auto;
+  }
+
+  .landing-page .header .hero .floats .float-1 {
+    left: -120px;
+    right: auto;
+  }
+
+  .landing-page .header .hero .floats .float-2 {
+    left: 210px;
+    right: auto;
+  }
+
+  .landing-page .header .hero .floats .float-3 {
+    left: 110px;
+    right: auto;
+  }
+
+  .landing-page .header .links .brand img {
+    left: 0;
+  }
+
+  .landing-page .fa-external-link {
+    padding-right: 5px;
+    padding-left: 0 !important;
+  }
+
+  .landing-page .features #mastodon-timeline {
+    margin-right: 0;
+    margin-left: 30px;
+  }
+
+  @media screen and (min-width: 631px) {
     .column,
     .drawer {
       padding-left: 5px;
@@ -139,11 +255,6 @@ body.rtl {
         padding-left: 5px;
         padding-right: 10px;
       }
-
-      &:last-child {
-        padding-right: 0;
-        padding-left: 10px;
-      }
     }
 
     .columns-area > div {
diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss
index baacb4913..453070b7c 100644
--- a/app/javascript/styles/stream_entries.scss
+++ b/app/javascript/styles/stream_entries.scss
@@ -8,6 +8,7 @@
     .detailed-status.light,
     .status.light {
       border-bottom: 1px solid $ui-secondary-color;
+      animation: none;
     }
 
     &:last-child {
@@ -34,6 +35,14 @@
         }
       }
     }
+
+    @media screen and (max-width: 740px) {
+      &,
+      .detailed-status.light,
+      .status.light {
+        border-radius: 0 !important;
+      }
+    }
   }
 
   &.with-header {
@@ -44,6 +53,14 @@
         .status.light {
           border-radius: 0;
         }
+
+        &:last-child {
+          &,
+          .detailed-status.light,
+          .status.light {
+            border-radius: 0 0 4px 4px;
+          }
+        }
       }
     }
   }
@@ -92,9 +109,9 @@
     .display-name {
       display: block;
       max-width: 100%;
-      overflow: hidden;
-      white-space: nowrap;
-      text-overflow: ellipsis;
+      //overflow: hidden;
+      //white-space: nowrap;
+      //text-overflow: ellipsis;
 
       strong {
         font-weight: 500;
@@ -123,19 +140,6 @@
         }
       }
     }
-
-    .status__attachments {
-      margin-top: 8px;
-      overflow: hidden;
-      width: 100%;
-      box-sizing: border-box;
-      position: relative;
-
-      .status__attachments__inner {
-        display: flex;
-        height: 214px;
-      }
-    }
   }
 
   .detailed-status.light {
@@ -216,139 +220,35 @@
       }
     }
 
-    .detailed-status__attachments {
-      margin-top: 8px;
-      overflow: hidden;
-      width: 100%;
-      box-sizing: border-box;
-      position: relative;
+    .status-card {
+      border-color: lighten($ui-secondary-color, 4%);
+      color: darken($ui-primary-color, 4%);
 
-      .status__attachments__inner {
-        display: flex;
-        height: 360px;
+      &:hover {
+        background: lighten($ui-secondary-color, 4%);
       }
     }
 
-    .video-player {
-      margin-top: 8px;
-      height: 300px;
-      overflow: hidden;
-      position: relative;
-
-      video {
-        position: relative;
-        z-index: 1;
-        width: 100%;
-        height: 100%;
-        object-fit: cover;
-        top: 50%;
-        transform: translateY(-50%);
-      }
-    }
-  }
-
-  .media-item,
-  .video-item {
-    box-sizing: border-box;
-    position: relative;
-    left: auto;
-    top: auto;
-    right: auto;
-    bottom: auto;
-    float: left;
-    border: medium none;
-    display: block;
-    flex: 1 1 auto;
-    width: 100%;
-    height: 100%;
-    overflow: hidden;
-    margin-right: 2px;
-
-    &:last-child {
-      margin-right: 0;
-    }
-
-    a {
-      display: block;
-      width: 100%;
-      height: 100%;
-      background: no-repeat scroll center center / cover;
-      text-decoration: none;
-      cursor: zoom-in;
+    .status-card__title,
+    .status-card__description {
+      color: $ui-base-color;
     }
 
-    video {
-      position: relative;
-      z-index: 1;
-      width: 100%;
-      height: 100%;
-      object-fit: cover;
-      top: 50%;
-      transform: translateY(-50%);
-    }
-  }
-
-  .video-item {
-    a {
-      cursor: pointer;
-    }
-
-    .video-item__play {
-      position: absolute;
-      top: 50%;
-      left: 50%;
-      font-size: 36px;
-      transform: translate(-50%, -50%);
-      padding: 5px;
-      border-radius: 100px;
-      color: rgba($primary-text-color, 0.8);
-      z-index: 1;
+    .status-card__image {
+      background: $ui-secondary-color;
     }
   }
 
   .media-spoiler {
     background: $ui-primary-color;
-    width: 100%;
-    height: 100%;
-    cursor: pointer;
-    position: absolute;
-    top: 0;
-    left: 0;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    flex-direction: column;
-    text-align: center;
+    color: $white;
     transition: all 100ms linear;
-    z-index: 2;
 
-    &:hover {
+    &:hover,
+    &:active,
+    &:focus {
       background: darken($ui-primary-color, 5%);
-    }
-
-    span {
-      display: block;
-
-      &:first-child {
-        font-size: 14px;
-      }
-
-      &:last-child {
-        font-size: 11px;
-        font-weight: 500;
-      }
-    }
-  }
-
-  .media-spoiler-wrapper {
-    &.media-spoiler-wrapper__visible {
-      .media-spoiler {
-        display: none;
-      }
-
-      .spoiler-button {
-        display: block;
-      }
+      color: unset;
     }
   }
 
@@ -382,19 +282,52 @@
 
 .embed {
   .activity-stream {
-    border-radius: 4px;
     box-shadow: none;
 
     .entry {
-      &:last-child {
-        border-radius: 0 0 4px 4px;
-      }
 
-      &:first-child {
-        border-radius: 4px 4px 0 0;
+      .detailed-status.light {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: space-between;
+        align-items: flex-start;
 
-        &:last-child {
-          border-radius: 4px;
+        .detailed-status__display-name {
+          flex: 1;
+          margin: 0 5px 15px 0;
+        }
+
+        .button.button-secondary.logo-button {
+          flex: 0 auto;
+          font-size: 14px;
+
+          svg {
+            width: 20px;
+            height: auto;
+            vertical-align: middle;
+            margin-right: 5px;
+
+            path:first-child {
+              fill: $ui-primary-color;
+            }
+
+            path:last-child {
+              fill: $simple-background-color;
+            }
+          }
+
+          &:active,
+          &:focus,
+          &:hover {
+            svg path:first-child {
+              fill: lighten($ui-primary-color, 4%);
+            }
+          }
+        }
+
+        .status__content,
+        .detailed-status__meta {
+          flex: 100%;
         }
       }
     }
diff --git a/app/javascript/styles/tables.scss b/app/javascript/styles/tables.scss
index 6e54c59c0..ad46f5f9f 100644
--- a/app/javascript/styles/tables.scss
+++ b/app/javascript/styles/tables.scss
@@ -3,7 +3,6 @@
   max-width: 100%;
   border-spacing: 0;
   border-collapse: collapse;
-  margin-bottom: 20px;
 
   th,
   td {
@@ -43,19 +42,17 @@
     font-weight: 500;
   }
 
-  &.inline-table {
-    td,
-    th {
-      padding: 8px 0;
-    }
-
-    & > tbody > tr:nth-child(odd) > td,
-    & > tbody > tr:nth-child(odd) > th {
-      background: transparent;
-    }
+  &.inline-table > tbody > tr:nth-child(odd) > td,
+  &.inline-table > tbody > tr:nth-child(odd) > th {
+    background: transparent;
   }
 }
 
+.table-wrapper {
+  overflow: auto;
+  margin-bottom: 20px;
+}
+
 samp {
   font-family: 'mastodon-font-monospace', monospace;
 }
diff --git a/app/javascript/themes/default/theme.yml b/app/javascript/themes/default/theme.yml
new file mode 100644
index 000000000..6a7a872b4
--- /dev/null
+++ b/app/javascript/themes/default/theme.yml
@@ -0,0 +1,9 @@
+#  (REQUIRED) Name must be unique across all installed themes.
+name: default
+
+#  (REQUIRED) The location of the pack file inside `pack_directory`.
+pack: application.js
+
+#  (OPTIONAL) The directory which contains the pack file.
+#  Defaults to the theme directory (`app/javascript/themes/[theme]`).
+pack_directory: app/javascript/packs
diff --git a/app/javascript/themes/spin/pack.js b/app/javascript/themes/spin/pack.js
new file mode 100644
index 000000000..dab0e93a4
--- /dev/null
+++ b/app/javascript/themes/spin/pack.js
@@ -0,0 +1,2 @@
+import 'packs/application';
+import 'themes/spin/style';
diff --git a/app/javascript/themes/spin/style.scss b/app/javascript/themes/spin/style.scss
new file mode 100644
index 000000000..1a9381fd0
--- /dev/null
+++ b/app/javascript/themes/spin/style.scss
@@ -0,0 +1,14 @@
+:root:root:root {
+  .button, .icon-button, .emoji-button, .account__avatar, .account__avatar-overlay {
+    animation: spin 4s linear infinite;
+  }
+}
+
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
diff --git a/app/javascript/themes/spin/theme.yml b/app/javascript/themes/spin/theme.yml
new file mode 100644
index 000000000..a684997dc
--- /dev/null
+++ b/app/javascript/themes/spin/theme.yml
@@ -0,0 +1,2 @@
+name: spin
+pack: pack.js
\ No newline at end of file
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
new file mode 100644
index 000000000..b06dd6194
--- /dev/null
+++ b/app/lib/activitypub/activity.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity
+  include JsonLdHelper
+
+  def initialize(json, account)
+    @json    = json
+    @account = account
+    @object  = @json['object']
+  end
+
+  def perform
+    raise NotImplementedError
+  end
+
+  class << self
+    def factory(json, account)
+      @json = json
+      klass&.new(json, account)
+    end
+
+    private
+
+    def klass
+      case @json['type']
+      when 'Create'
+        ActivityPub::Activity::Create
+      when 'Announce'
+        ActivityPub::Activity::Announce
+      when 'Delete'
+        ActivityPub::Activity::Delete
+      when 'Follow'
+        ActivityPub::Activity::Follow
+      when 'Like'
+        ActivityPub::Activity::Like
+      when 'Block'
+        ActivityPub::Activity::Block
+      when 'Update'
+        ActivityPub::Activity::Update
+      when 'Undo'
+        ActivityPub::Activity::Undo
+      when 'Accept'
+        ActivityPub::Activity::Accept
+      when 'Reject'
+        ActivityPub::Activity::Reject
+      end
+    end
+  end
+
+  protected
+
+  def status_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
+  end
+
+  def account_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+  end
+
+  def object_uri
+    @object_uri ||= value_or_id(@object)
+  end
+
+  def redis
+    Redis.current
+  end
+
+  def distribute(status)
+    notify_about_reblog(status) if reblog_of_local_account?(status)
+    notify_about_mentions(status)
+    crawl_links(status)
+    distribute_to_followers(status)
+  end
+
+  def reblog_of_local_account?(status)
+    status.reblog? && status.reblog.account.local?
+  end
+
+  def notify_about_reblog(status)
+    NotifyService.new.call(status.reblog.account, status)
+  end
+
+  def notify_about_mentions(status)
+    status.mentions.includes(:account).each do |mention|
+      next unless mention.account.local? && audience_includes?(mention.account)
+      NotifyService.new.call(mention.account, mention)
+    end
+  end
+
+  def crawl_links(status)
+    return if status.spoiler_text?
+    LinkCrawlWorker.perform_async(status.id)
+  end
+
+  def distribute_to_followers(status)
+    ::DistributionWorker.perform_async(status.id)
+  end
+
+  def delete_arrived_first?(uri)
+    redis.exists("delete_upon_arrival:#{@account.id}:#{uri}")
+  end
+
+  def delete_later!(uri)
+    redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
+  end
+end
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
new file mode 100644
index 000000000..bd90c9019
--- /dev/null
+++ b/app/lib/activitypub/activity/accept.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Accept < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Follow'
+      accept_follow
+    end
+  end
+
+  private
+
+  def accept_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+    follow_request&.authorize!
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['actor'])
+  end
+end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
new file mode 100644
index 000000000..4516454e1
--- /dev/null
+++ b/app/lib/activitypub/activity/announce.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Announce < ActivityPub::Activity
+  def perform
+    original_status   = status_from_uri(object_uri)
+    original_status ||= fetch_remote_original_status
+
+    return if original_status.nil? || delete_arrived_first?(@json['id'])
+
+    status = Status.find_by(account: @account, reblog: original_status)
+
+    return status unless status.nil?
+
+    status = Status.create!(
+      account: @account,
+      reblog: original_status,
+      uri: @json['id'],
+      created_at: @json['published'] || Time.now.utc
+    )
+    distribute(status)
+    status
+  end
+
+  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)
+    elsif @object['url'].present?
+      ::FetchRemoteStatusService.new.call(@object['url'])
+    end
+  end
+end
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
new file mode 100644
index 000000000..f630d5db2
--- /dev/null
+++ b/app/lib/activitypub/activity/block.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Block < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    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)
+  end
+end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
new file mode 100644
index 000000000..4e19b3096
--- /dev/null
+++ b/app/lib/activitypub/activity/create.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Create < ActivityPub::Activity
+  def perform
+    return if delete_arrived_first?(object_uri) || unsupported_object_type?
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @status = find_existing_status
+        process_status if @status.nil?
+      end
+    end
+
+    @status
+  end
+
+  private
+
+  def process_status
+    ApplicationRecord.transaction do
+      @status = Status.create!(status_params)
+
+      process_tags(@status)
+      process_attachments(@status)
+    end
+
+    resolve_thread(@status)
+    distribute(@status)
+    forward_for_reply if @status.public_visibility? || @status.unlisted_visibility?
+  end
+
+  def find_existing_status
+    status   = status_from_uri(object_uri)
+    status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
+    status
+  end
+
+  def status_params
+    {
+      uri: @object['id'],
+      url: object_url || @object['id'],
+      account: @account,
+      text: text_from_content || '',
+      language: language_from_content,
+      spoiler_text: @object['summary'] || '',
+      created_at: @object['published'] || Time.now.utc,
+      reply: @object['inReplyTo'].present?,
+      sensitive: @object['sensitive'] || false,
+      visibility: visibility_from_audience,
+      thread: replied_to_status,
+      conversation: conversation_from_uri(@object['conversation']),
+    }
+  end
+
+  def process_tags(status)
+    return unless @object['tag'].is_a?(Array)
+
+    @object['tag'].each do |tag|
+      case tag['type']
+      when 'Hashtag'
+        process_hashtag tag, status
+      when 'Mention'
+        process_mention tag, status
+      when 'Emoji'
+        process_emoji tag, status
+      end
+    end
+  end
+
+  def process_hashtag(tag, status)
+    return if tag['name'].blank?
+
+    hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
+    hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+
+    status.tags << hashtag
+  end
+
+  def process_mention(tag, status)
+    return if tag['href'].blank?
+
+    account = account_from_uri(tag['href'])
+    account = FetchRemoteAccountService.new.call(tag['href']) if account.nil?
+    return if account.nil?
+    account.mentions.create(status: status)
+  end
+
+  def process_emoji(tag, _status)
+    return if tag['name'].blank? || tag['href'].blank?
+
+    shortcode = tag['name'].delete(':')
+    emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
+
+    return if !emoji.nil? || skip_download?
+
+    emoji = CustomEmoji.new(domain: @account.domain, shortcode: shortcode)
+    emoji.image_remote_url = tag['href']
+    emoji.save
+  end
+
+  def process_attachments(status)
+    return unless @object['attachment'].is_a?(Array)
+
+    @object['attachment'].each do |attachment|
+      next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
+
+      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
+      media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
+
+      next if skip_download?
+
+      media_attachment.file_remote_url = href
+      media_attachment.save
+    end
+  rescue Addressable::URI::InvalidURIError => e
+    Rails.logger.debug e
+  end
+
+  def resolve_thread(status)
+    return unless status.reply? && status.thread.nil?
+    ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
+  end
+
+  def conversation_from_uri(uri)
+    return nil if uri.nil?
+    return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
+    Conversation.find_by(uri: uri) || Conversation.create(uri: uri)
+  end
+
+  def visibility_from_audience
+    if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :public
+    elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :unlisted
+    elsif equals_or_includes?(@object['to'], @account.followers_url)
+      :private
+    else
+      :direct
+    end
+  end
+
+  def audience_includes?(account)
+    uri = ActivityPub::TagManager.instance.uri_for(account)
+    equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri)
+  end
+
+  def replied_to_status
+    return @replied_to_status if defined?(@replied_to_status)
+
+    if in_reply_to_uri.blank?
+      @replied_to_status = nil
+    else
+      @replied_to_status   = status_from_uri(in_reply_to_uri)
+      @replied_to_status ||= status_from_uri(@object['inReplyToAtomUri']) if @object['inReplyToAtomUri'].present?
+      @replied_to_status
+    end
+  end
+
+  def in_reply_to_uri
+    value_or_id(@object['inReplyTo'])
+  end
+
+  def text_from_content
+    if @object['content'].present?
+      @object['content']
+    elsif language_map?
+      @object['contentMap'].values.first
+    end
+  end
+
+  def language_from_content
+    return nil unless language_map?
+    @object['contentMap'].keys.first
+  end
+
+  def object_url
+    return if @object['url'].blank?
+
+    value = first_of_value(@object['url'])
+
+    return value if value.is_a?(String)
+
+    value['href']
+  end
+
+  def language_map?
+    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
+  end
+
+  def unsupported_object_type?
+    @object.is_a?(String) || !%w(Article Note).include?(@object['type'])
+  end
+
+  def unsupported_media_type?(mime_type)
+    mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
+  end
+
+  def skip_download?
+    return @skip_download if defined?(@skip_download)
+    @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
+  end
+
+  def reply_to_local?
+    !replied_to_status.nil? && replied_to_status.account.local?
+  end
+
+  def forward_for_reply
+    return unless @json['signature'].present? && reply_to_local?
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id)
+  end
+
+  def lock_options
+    { redis: Redis.current, key: "create:#{@object['id']}" }
+  end
+end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
new file mode 100644
index 000000000..4c6afb090
--- /dev/null
+++ b/app/lib/activitypub/activity/delete.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Delete < ActivityPub::Activity
+  def perform
+    if @account.uri == object_uri
+      delete_person
+    else
+      delete_note
+    end
+  end
+
+  private
+
+  def delete_person
+    SuspendAccountService.new.call(@account)
+  end
+
+  def delete_note
+    status   = Status.find_by(uri: object_uri, account: @account)
+    status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
+
+    delete_later!(object_uri)
+
+    return if status.nil?
+
+    forward_for_reblogs(status)
+    delete_now!(status)
+  end
+
+  def forward_for_reblogs(status)
+    return if @json['signature'].blank?
+
+    ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id|
+      [payload, account_id]
+    end
+  end
+
+  def delete_now!(status)
+    RemoveStatusService.new.call(status)
+  end
+
+  def payload
+    @payload ||= Oj.dump(@json)
+  end
+end
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
new file mode 100644
index 000000000..8adbbb9c3
--- /dev/null
+++ b/app/lib/activitypub/activity/follow.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Follow < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account)
+
+    # Fast-forward repeat follow requests
+    if @account.following?(target_account)
+      AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true)
+      return
+    end
+
+    follow_request = FollowRequest.create!(account: @account, target_account: target_account)
+
+    if target_account.locked?
+      NotifyService.new.call(target_account, follow_request)
+    else
+      AuthorizeFollowService.new.call(@account, target_account)
+      NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
+    end
+  end
+end
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
new file mode 100644
index 000000000..674d5fe47
--- /dev/null
+++ b/app/lib/activitypub/activity/like.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Like < ActivityPub::Activity
+  def perform
+    original_status = status_from_uri(object_uri)
+
+    return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
+
+    favourite = original_status.favourites.create!(account: @account)
+    NotifyService.new.call(original_status.account, favourite)
+  end
+end
diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb
new file mode 100644
index 000000000..d815feeb6
--- /dev/null
+++ b/app/lib/activitypub/activity/reject.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Reject < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Follow'
+      reject_follow
+    end
+  end
+
+  private
+
+  def reject_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+    follow_request&.reject!
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['actor'])
+  end
+end
diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb
new file mode 100644
index 000000000..4b0905de2
--- /dev/null
+++ b/app/lib/activitypub/activity/undo.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Undo < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Announce'
+      undo_announce
+    when 'Follow'
+      undo_follow
+    when 'Like'
+      undo_like
+    when 'Block'
+      undo_block
+    end
+  end
+
+  private
+
+  def undo_announce
+    status = Status.find_by(uri: object_uri, account: @account)
+
+    if status.nil?
+      delete_later!(object_uri)
+    else
+      RemoveStatusService.new.call(status)
+    end
+  end
+
+  def undo_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.following?(target_account)
+      @account.unfollow!(target_account)
+    elsif @account.requested?(target_account)
+      FollowRequest.find_by(account: @account, target_account: target_account)&.destroy
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_like
+    status = status_from_uri(target_uri)
+
+    return if status.nil? || !status.account.local?
+
+    if @account.favourited?(status)
+      favourite = status.favourites.where(account: @account).first
+      favourite&.destroy
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_block
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.blocking?(target_account)
+      UnblockService.new.call(@account, target_account)
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['object'])
+  end
+end
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
new file mode 100644
index 000000000..0134b4015
--- /dev/null
+++ b/app/lib/activitypub/activity/update.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Update < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Person'
+      update_account
+    end
+  end
+
+  private
+
+  def update_account
+    return if @account.uri != object_uri
+    ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
+  end
+end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 0a70207bc..790d2025c 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -1,13 +1,36 @@
 # frozen_string_literal: true
 
 class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
+  CONTEXT = {
+    '@context': [
+      'https://www.w3.org/ns/activitystreams',
+      'https://w3id.org/security/v1',
+
+      {
+        'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
+        'sensitive'                 => 'as:sensitive',
+        'Hashtag'                   => 'as:Hashtag',
+        'ostatus'                   => 'http://ostatus.org#',
+        'atomUri'                   => 'ostatus:atomUri',
+        'inReplyToAtomUri'          => 'ostatus:inReplyToAtomUri',
+        'conversation'              => 'ostatus:conversation',
+        'toot'                      => 'http://joinmastodon.org/ns#',
+        'Emoji'                     => 'toot:Emoji',
+      },
+    ],
+  }.freeze
+
   def self.default_key_transform
     :camel_lower
   end
 
+  def self.transform_key_casing!(value, _options)
+    ActivityPub::CaseTransform.camel_lower(value)
+  end
+
   def serializable_hash(options = nil)
     options = serialization_options(options)
-    serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
-    self.class.transform_key_casing!(serialized_hash, instance_options)
+    serialized_hash = ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)
+    CONTEXT.merge(self.class.transform_key_casing!(serialized_hash, instance_options))
   end
 end
diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb
new file mode 100644
index 000000000..7f716f862
--- /dev/null
+++ b/app/lib/activitypub/case_transform.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module ActivityPub::CaseTransform
+  class << self
+    def camel_lower_cache
+      @camel_lower_cache ||= {}
+    end
+
+    def camel_lower(value)
+      case value
+      when Array then value.map { |item| camel_lower(item) }
+      when Hash then value.deep_transform_keys! { |key| camel_lower(key) }
+      when Symbol then camel_lower(value.to_s).to_sym
+      when String
+        camel_lower_cache[value] ||= if value.start_with?('_:')
+                                       '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
+                                     else
+                                       value.underscore.camelize(:lower)
+                                     end
+      else value
+      end
+    end
+  end
+end
diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb
new file mode 100644
index 000000000..adb8b6cdf
--- /dev/null
+++ b/app/lib/activitypub/linked_data_signature.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class ActivityPub::LinkedDataSignature
+  include JsonLdHelper
+
+  CONTEXT = 'https://w3id.org/identity/v1'
+
+  def initialize(json)
+    @json = json.with_indifferent_access
+  end
+
+  def verify_account!
+    return unless @json['signature'].is_a?(Hash)
+
+    type        = @json['signature']['type']
+    creator_uri = @json['signature']['creator']
+    signature   = @json['signature']['signatureValue']
+
+    return unless type == 'RsaSignature2017'
+
+    creator   = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
+    creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri)
+
+    return if creator.nil?
+
+    options_hash   = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash  = hash(@json.without('signature'))
+    to_be_verified = options_hash + document_hash
+
+    if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified)
+      creator
+    end
+  end
+
+  def sign!(creator)
+    options = {
+      'type'    => 'RsaSignature2017',
+      'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
+      'created' => Time.now.utc.iso8601,
+    }
+
+    options_hash  = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash = hash(@json.without('signature'))
+    to_be_signed  = options_hash + document_hash
+
+    signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
+
+    @json.merge('signature' => options.merge('signatureValue' => signature))
+  end
+
+  private
+
+  def hash(obj)
+    Digest::SHA256.hexdigest(canonicalize(obj))
+  end
+end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index ec42bcad3..4ec3b8c56 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -6,6 +6,8 @@ class ActivityPub::TagManager
   include Singleton
   include RoutingHelper
 
+  CONTEXT = 'https://www.w3.org/ns/activitystreams'
+
   COLLECTIONS = {
     public: 'https://www.w3.org/ns/activitystreams#Public',
   }.freeze
@@ -17,6 +19,7 @@ class ActivityPub::TagManager
     when :person
       short_account_url(target)
     when :note, :comment, :activity
+      return activity_account_status_url(target.account, target) if target.reblog?
       short_account_status_url(target.account, target)
     end
   end
@@ -28,10 +31,17 @@ class ActivityPub::TagManager
     when :person
       account_url(target)
     when :note, :comment, :activity
+      return activity_account_status_url(target.account, target) if target.reblog?
       account_status_url(target.account, target)
     end
   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?
+
+    activity_account_status_url(target.account, target)
+  end
+
   # Primary audience of a status
   # Public statuses go out to primarily the public collection
   # Unlisted and private statuses go out primarily to the followers collection
@@ -66,4 +76,34 @@ class ActivityPub::TagManager
 
     cc
   end
+
+  def local_uri?(uri)
+    uri  = Addressable::URI.parse(uri)
+    host = uri.normalized_host
+    host = "#{host}:#{uri.port}" if uri.port
+
+    !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host))
+  end
+
+  def uri_to_local_id(uri, param = :id)
+    path_params = Rails.application.routes.recognize_path(uri)
+    path_params[param]
+  end
+
+  def uri_to_resource(uri, klass)
+    if local_uri?(uri)
+      case klass.name
+      when 'Account'
+        klass.find_local(uri_to_local_id(uri, :username))
+      else
+        StatusFinder.new(uri).status
+      end
+    elsif OStatus::TagManager.instance.local_id?(uri)
+      klass.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s))
+    else
+      klass.find_by(uri: uri.split('#').first)
+    end
+  rescue ActiveRecord::RecordNotFound
+    nil
+  end
 end
diff --git a/app/lib/emoji.rb b/app/lib/emoji.rb
deleted file mode 100644
index 45b7f53de..000000000
--- a/app/lib/emoji.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-require 'singleton'
-
-class Emoji
-  include Singleton
-
-  def initialize
-    data = Oj.load(File.open(Rails.root.join('lib', 'assets', 'emoji.json')))
-
-    @map = {}
-
-    data.each do |_, emoji|
-      keys    = [emoji['shortname']] + emoji['aliases']
-      unicode = codepoint_to_unicode(emoji['unicode'])
-
-      keys.each do |key|
-        @map[key] = unicode
-      end
-    end
-  end
-
-  def unicode(shortcode)
-    @map[shortcode]
-  end
-
-  def names
-    @map.keys
-  end
-
-  private
-
-  def codepoint_to_unicode(codepoint)
-    if codepoint.include?('-')
-      codepoint.split('-').map(&:hex).pack('U*')
-    else
-      [codepoint.hex].pack('U')
-    end
-  end
-end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 7b89305ac..42cd72990 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -9,7 +9,7 @@ class Formatter
 
   include ActionView::Helpers::TextHelper
 
-  def format(status)
+  def format(status, options = {})
     if status.reblog?
       prepend_reblog = status.reblog.account.acct
       status         = status.proper
@@ -19,7 +19,11 @@ class Formatter
 
     raw_content = status.text
 
-    return reformat(raw_content) unless status.local?
+    unless status.local?
+      html = reformat(raw_content)
+      html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
+      return html.html_safe # rubocop:disable Rails/OutputSafety
+    end
 
     linkable_accounts = status.mentions.map(&:account)
     linkable_accounts << status.account
@@ -27,6 +31,7 @@ class Formatter
     html = raw_content
     html = "RT @#{prepend_reblog} #{html}" if prepend_reblog
     html = encode_and_link_urls(html, linkable_accounts)
+    html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
     html = simple_format(html, {}, sanitize: false)
     html = html.delete("\n")
 
@@ -34,12 +39,14 @@ class Formatter
   end
 
   def reformat(html)
-    sanitize(html, Sanitize::Config::MASTODON_STRICT).html_safe # rubocop:disable Rails/OutputSafety
+    sanitize(html, Sanitize::Config::MASTODON_STRICT)
   end
 
   def plaintext(status)
     return status.text if status.local?
-    strip_tags(status.text)
+
+    text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
+    strip_tags(text)
   end
 
   def simplified_format(account)
@@ -56,6 +63,12 @@ class Formatter
     Sanitize.fragment(html, config)
   end
 
+  def format_spoiler(status)
+    html = encode(status.spoiler_text)
+    html = encode_custom_emojis(html, status.emojis)
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
   private
 
   def encode(html)
@@ -76,6 +89,47 @@ class Formatter
     end
   end
 
+  def encode_custom_emojis(html, emojis)
+    return html if emojis.empty?
+
+    emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url)] }.to_h
+
+    i                     = -1
+    inside_tag            = false
+    inside_shortname      = false
+    shortname_start_index = -1
+
+    while i + 1 < html.size
+      i += 1
+
+      if inside_shortname && html[i] == ':'
+        shortcode = html[shortname_start_index + 1..i - 1]
+        emoji     = emoji_map[shortcode]
+
+        if emoji
+          replacement = "<img draggable=\"false\" class=\"emojione\" alt=\":#{shortcode}:\" title=\":#{shortcode}:\" src=\"#{emoji}\" />"
+          before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
+          html        = before_html + replacement + html[i + 1..-1]
+          i          += replacement.size - (shortcode.size + 2) - 1
+        else
+          i -= 1
+        end
+
+        inside_shortname = false
+      elsif inside_tag && html[i] == '>'
+        inside_tag = false
+      elsif html[i] == '<'
+        inside_tag       = true
+        inside_shortname = false
+      elsif !inside_tag && html[i] == ':'
+        inside_shortname      = true
+        shortname_start_index = i
+      end
+    end
+
+    html
+  end
+
   def rewrite(text, entities)
     chars = text.to_s.to_char_a
 
@@ -104,7 +158,7 @@ class Formatter
     html_attrs     = { target: '_blank', rel: 'nofollow noopener' }
 
     Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), normalized_url, html_attrs)
-  rescue Addressable::URI::InvalidURIError
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     encode(entity[:url])
   end
 
@@ -131,13 +185,13 @@ class Formatter
   end
 
   def link_html(url)
-    url    = Addressable::URI.parse(url).display_uri.to_s
+    url    = Addressable::URI.parse(url).to_s
     prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s
     text   = url[prefix.length, 30]
     suffix = url[prefix.length + 30..-1]
     cutoff = url[prefix.length..-1].length > 30
 
-    "<span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span>"
+    "<span class=\"invisible\">#{encode(prefix)}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{encode(text)}</span><span class=\"invisible\">#{encode(suffix)}</span>"
   end
 
   def hashtag_html(tag)
diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb
index cc7509fdc..a42460e10 100644
--- a/app/lib/language_detector.rb
+++ b/app/lib/language_detector.rb
@@ -1,37 +1,43 @@
 # frozen_string_literal: true
 
 class LanguageDetector
-  attr_reader :text, :account
+  include Singleton
 
-  def initialize(text, account = nil)
-    @text = text
-    @account = account
+  def initialize
     @identifier = CLD3::NNetLanguageIdentifier.new(1, 2048)
   end
 
-  def to_iso_s
-    detected_language_code || default_locale
+  def detect(text, account)
+    detect_language_code(text) || default_locale(account)
   end
 
-  def prepared_text
-    simplified_text.strip
+  def language_names
+    @language_names =
+      CLD3::TaskContextParams::LANGUAGE_NAMES.map { |name| iso6391(name.to_s).to_sym }
+                                             .uniq
   end
 
   private
 
-  def detected_language_code
-    result.language.to_sym if detected_language_reliable?
+  def prepare_text(text)
+    simplify_text(text).strip
   end
 
-  def result
-    @result ||= @identifier.find_language(prepared_text)
+  def detect_language_code(text)
+    result = @identifier.find_language(prepare_text(text))
+    iso6391(result.language.to_s).to_sym if result.reliable?
   end
 
-  def detected_language_reliable?
-    result.reliable?
+  def iso6391(bcp47)
+    iso639 = bcp47.split('-').first
+
+    # CLD3 returns grandfathered language code for Hebrew
+    return 'he' if iso639 == 'iw'
+
+    ISO_639.find(iso639).alpha2
   end
 
-  def simplified_text
+  def simplify_text(text)
     text.dup.tap do |new_text|
       new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
       new_text.gsub!(Account::MENTION_RE, '')
@@ -40,7 +46,7 @@ class LanguageDetector
     end
   end
 
-  def default_locale
-    account&.user_locale&.to_sym || nil
+  def default_locale(account)
+    account.user_locale&.to_sym
   end
 end
diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb
index e1477f0eb..039381397 100644
--- a/app/lib/ostatus/activity/base.rb
+++ b/app/lib/ostatus/activity/base.rb
@@ -11,39 +11,61 @@ class OStatus::Activity::Base
   end
 
   def verb
-    raw = @xml.at_xpath('./activity:verb', activity: TagManager::AS_XMLNS).content
-    TagManager::VERBS.key(raw)
+    raw = @xml.at_xpath('./activity:verb', activity: OStatus::TagManager::AS_XMLNS).content
+    OStatus::TagManager::VERBS.key(raw)
   rescue
     :post
   end
 
   def type
-    raw = @xml.at_xpath('./activity:object-type', activity: TagManager::AS_XMLNS).content
-    TagManager::TYPES.key(raw)
+    raw = @xml.at_xpath('./activity:object-type', activity: OStatus::TagManager::AS_XMLNS).content
+    OStatus::TagManager::TYPES.key(raw)
   rescue
     :activity
   end
 
   def id
-    @xml.at_xpath('./xmlns:id', xmlns: TagManager::XMLNS).content
+    @xml.at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content
   end
 
   def url
-    link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS)
+    link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' }
     link.nil? ? nil : link['href']
   end
 
+  def activitypub_uri
+    link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) }
+    link.nil? ? nil : link['href']
+  end
+
+  def activitypub_uri?
+    activitypub_uri.present?
+  end
+
   private
 
   def find_status(uri)
-    if TagManager.instance.local_id?(uri)
-      local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status')
+    if OStatus::TagManager.instance.local_id?(uri)
+      local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status')
+      return Status.find_by(id: local_id)
+    elsif ActivityPub::TagManager.instance.local_uri?(uri)
+      local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri)
       return Status.find_by(id: local_id)
     end
 
     Status.find_by(uri: uri)
   end
 
+  def find_activitypub_status(uri, href)
+    tag_matches = /tag:([^,:]+)[^:]*:objectId=([\d]+)/.match(uri)
+    href_matches = %r{/users/([^/]+)}.match(href)
+
+    unless tag_matches.nil? || href_matches.nil?
+      uri = "https://#{tag_matches[1]}/users/#{href_matches[1]}/statuses/#{tag_matches[2]}"
+      Status.find_by(uri: uri)
+    end
+  end
+
   def redis
     Redis.current
   end
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index e22f746f2..2687776f9 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -9,31 +9,49 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
 
     return [nil, false] if @account.suspended?
 
-    Rails.logger.debug "Creating remote status #{id}"
+    if activitypub_uri? && [:public, :unlisted].include?(visibility_scope)
+      result = perform_via_activitypub
+      return result if result.first.present?
+    end
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        # Return early if status already exists in db
+        @status = find_status(id)
+        return [@status, false] unless @status.nil?
+        @status = process_status
+      end
+    end
 
-    # Return early if status already exists in db
-    status = find_status(id)
-
-    return [status, false] unless status.nil?
-
-    status = Status.create!(
-      uri: id,
-      url: url,
-      account: @account,
-      reblog: reblog,
-      text: content,
-      spoiler_text: content_warning,
-      created_at: published,
-      reply: thread?,
-      language: content_language,
-      visibility: visibility_scope,
-      conversation: find_or_create_conversation,
-      thread: thread? ? find_status(thread.first) : nil
-    )
-
-    save_mentions(status)
-    save_hashtags(status)
-    save_media(status)
+    [@status, true]
+  end
+
+  def process_status
+    Rails.logger.debug "Creating remote status #{id}"
+    cached_reblog = reblog
+    status = nil
+
+    ApplicationRecord.transaction do
+      status = Status.create!(
+        uri: id,
+        url: url,
+        account: @account,
+        reblog: cached_reblog,
+        text: content,
+        spoiler_text: content_warning,
+        created_at: published,
+        reply: thread?,
+        language: content_language,
+        visibility: visibility_scope,
+        conversation: find_or_create_conversation,
+        thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil
+      )
+
+      save_mentions(status)
+      save_hashtags(status)
+      save_media(status)
+      save_emojis(status)
+    end
 
     if thread? && status.thread.nil?
       Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}"
@@ -45,46 +63,50 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
     DistributionWorker.perform_async(status.id)
 
-    [status, true]
+    status
+  end
+
+  def perform_via_activitypub
+    [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false]
   end
 
   def content
-    @xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
+    @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS).content
   end
 
   def content_language
-    @xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS)['xml:lang']&.presence || 'en'
+    @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS)['xml:lang']&.presence || 'en'
   end
 
   def content_warning
-    @xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || ''
+    @xml.at_xpath('./xmlns:summary', xmlns: OStatus::TagManager::XMLNS)&.content || ''
   end
 
   def visibility_scope
-    @xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content&.to_sym || :public
+    @xml.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content&.to_sym || :public
   end
 
   def published
-    @xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content
+    @xml.at_xpath('./xmlns:published', xmlns: OStatus::TagManager::XMLNS).content
   end
 
   def thread?
-    !@xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS).nil?
+    !@xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS).nil?
   end
 
   def thread
-    thr = @xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS)
+    thr = @xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS)
     [thr['ref'], thr['href']]
   end
 
   private
 
   def find_or_create_conversation
-    uri = @xml.at_xpath('./ostatus:conversation', ostatus: TagManager::OS_XMLNS)&.attribute('ref')&.content
+    uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content
     return if uri.nil?
 
-    if TagManager.instance.local_id?(uri)
-      local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')
+    if OStatus::TagManager.instance.local_id?(uri)
+      local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')
       return Conversation.find_by(id: local_id)
     end
 
@@ -94,8 +116,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
   def save_mentions(parent)
     processed_account_ids = []
 
-    @xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each do |link|
-      next if [TagManager::TYPES[:group], TagManager::TYPES[:collection]].include? link['ostatus:object-type']
+    @xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
+      next if [OStatus::TagManager::TYPES[:group], OStatus::TagManager::TYPES[:collection]].include? link['ostatus:object-type']
 
       mentioned_account = account_from_href(link['href'])
 
@@ -109,14 +131,14 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
   end
 
   def save_hashtags(parent)
-    tags = @xml.xpath('./xmlns:category', xmlns: TagManager::XMLNS).map { |category| category['term'] }.select(&:present?)
+    tags = @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).map { |category| category['term'] }.select(&:present?)
     ProcessHashtagsService.new.call(parent, tags)
   end
 
   def save_media(parent)
     do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
 
-    @xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link|
+    @xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
       next unless link['href']
 
       media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href'])
@@ -137,6 +159,25 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     end
   end
 
+  def save_emojis(parent)
+    do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
+
+    return if do_not_download
+
+    @xml.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
+      next unless link['href'] && link['name']
+
+      shortcode = link['name'].delete(':')
+      emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: parent.account.domain)
+
+      next unless emoji.nil?
+
+      emoji = CustomEmoji.new(shortcode: shortcode, domain: parent.account.domain)
+      emoji.image_remote_url = link['href']
+      emoji.save
+    end
+  end
+
   def account_from_href(href)
     url = Addressable::URI.parse(href).normalize
 
@@ -146,4 +187,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
       Account.where(uri: href).or(Account.where(url: href)).first || FetchRemoteAccountService.new.call(href)
     end
   end
+
+  def lock_options
+    { redis: Redis.current, key: "create:#{id}" }
+  end
 end
diff --git a/app/lib/ostatus/activity/deletion.rb b/app/lib/ostatus/activity/deletion.rb
index 860faf501..c98f5ee0a 100644
--- a/app/lib/ostatus/activity/deletion.rb
+++ b/app/lib/ostatus/activity/deletion.rb
@@ -3,7 +3,9 @@
 class OStatus::Activity::Deletion < OStatus::Activity::Base
   def perform
     Rails.logger.debug "Deleting remote status #{id}"
-    status = Status.find_by(uri: id, account: @account)
+
+    status   = Status.find_by(uri: id, account: @account)
+    status ||= Status.find_by(uri: activitypub_uri, account: @account) if activitypub_uri?
 
     if status.nil?
       redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id)
diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb
index ecec6886c..5b204b6d8 100644
--- a/app/lib/ostatus/activity/remote.rb
+++ b/app/lib/ostatus/activity/remote.rb
@@ -2,6 +2,10 @@
 
 class OStatus::Activity::Remote < OStatus::Activity::Base
   def perform
-    find_status(id) || FetchRemoteStatusService.new.call(url)
+    if activitypub_uri?
+      find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url)
+    else
+      find_status(id) || FetchRemoteStatusService.new.call(url)
+    end
   end
 end
diff --git a/app/lib/ostatus/activity/share.rb b/app/lib/ostatus/activity/share.rb
index 290008021..5ca601415 100644
--- a/app/lib/ostatus/activity/share.rb
+++ b/app/lib/ostatus/activity/share.rb
@@ -10,7 +10,7 @@ class OStatus::Activity::Share < OStatus::Activity::Creation
   end
 
   def object
-    @xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS)
+    @xml.at_xpath('.//activity:object', activity: OStatus::TagManager::AS_XMLNS)
   end
 
   private
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 0d62361be..a1ac11a51 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -15,10 +15,10 @@ class OStatus::AtomSerializer
   def author(account)
     author = Ox::Element.new('author')
 
-    uri = TagManager.instance.uri_for(account)
+    uri = OStatus::TagManager.instance.uri_for(account)
 
     append_element(author, 'id', uri)
-    append_element(author, 'activity:object-type', TagManager::TYPES[:person])
+    append_element(author, 'activity:object-type', OStatus::TagManager::TYPES[:person])
     append_element(author, 'uri', uri)
     append_element(author, 'name', account.username)
     append_element(author, 'email', account.local? ? account.local_username_and_domain : account.acct)
@@ -65,27 +65,30 @@ class OStatus::AtomSerializer
 
     add_namespaces(entry) if root
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type))
+    append_element(entry, 'id', OStatus::TagManager.instance.uri_for(stream_entry.status))
     append_element(entry, 'published', stream_entry.created_at.iso8601)
     append_element(entry, 'updated', stream_entry.updated_at.iso8601)
     append_element(entry, 'title', stream_entry&.status&.title || "#{stream_entry.account.acct} deleted status")
 
     entry << author(stream_entry.account) if root
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[stream_entry.object_type])
-    append_element(entry, 'activity:verb', TagManager::VERBS[stream_entry.verb])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[stream_entry.object_type])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[stream_entry.verb])
 
     entry << object(stream_entry.target) if stream_entry.targeted?
 
     if stream_entry.status.nil?
       append_element(entry, 'content', 'Deleted status')
+    elsif stream_entry.status.destroyed?
+      append_element(entry, 'content', 'Deleted status')
+      append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(stream_entry.status)) if stream_entry.account.local?
     else
       serialize_status_attributes(entry, stream_entry.status)
     end
 
-    append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: account_stream_entry_url(stream_entry.account, stream_entry))
+    append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(stream_entry.status))
     append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom'))
-    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
+    append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
     append_element(entry, 'ostatus:conversation', nil, ref: conversation_uri(stream_entry.status.conversation)) unless stream_entry&.status&.conversation_id.nil?
 
     entry
@@ -94,20 +97,20 @@ class OStatus::AtomSerializer
   def object(status)
     object = Ox::Element.new('activity:object')
 
-    append_element(object, 'id', TagManager.instance.uri_for(status))
+    append_element(object, 'id', OStatus::TagManager.instance.uri_for(status))
     append_element(object, 'published', status.created_at.iso8601)
     append_element(object, 'updated', status.updated_at.iso8601)
     append_element(object, 'title', status.title)
 
     object << author(status.account)
 
-    append_element(object, 'activity:object-type', TagManager::TYPES[status.object_type])
-    append_element(object, 'activity:verb', TagManager::VERBS[status.verb])
+    append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[status.object_type])
+    append_element(object, 'activity:verb', OStatus::TagManager::VERBS[status.verb])
 
     serialize_status_attributes(object, status)
 
     append_element(object, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(status))
-    append_element(object, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(status.thread), href: TagManager.instance.url_for(status.thread)) unless status.thread.nil?
+    append_element(object, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(status.thread), href: TagManager.instance.url_for(status.thread)) unless status.thread.nil?
     append_element(object, 'ostatus:conversation', nil, ref: conversation_uri(status.conversation)) unless status.conversation_id.nil?
 
     object
@@ -119,14 +122,14 @@ class OStatus::AtomSerializer
 
     description = "#{follow.account.acct} started following #{follow.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(follow.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:follow])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:follow])
 
     object = author(follow.target_account)
     object.value = 'activity:object'
@@ -139,13 +142,13 @@ class OStatus::AtomSerializer
     entry = Ox::Element.new('entry')
     add_namespaces(entry)
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest'))
     append_element(entry, 'title', "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}")
 
     entry << author(follow_request.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:request_friend])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
 
     object = author(follow_request.target_account)
     object.value = 'activity:object'
@@ -158,19 +161,19 @@ class OStatus::AtomSerializer
     entry = Ox::Element.new('entry')
     add_namespaces(entry)
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
     append_element(entry, 'title', "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}")
 
     entry << author(follow_request.target_account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:authorize])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:authorize])
 
     object = Ox::Element.new('activity:object')
     object << author(follow_request.account)
 
-    append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
+    append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
 
     inner_object = author(follow_request.target_account)
     inner_object.value = 'activity:object'
@@ -184,19 +187,19 @@ class OStatus::AtomSerializer
     entry = Ox::Element.new('entry')
     add_namespaces(entry)
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
     append_element(entry, 'title', "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}")
 
     entry << author(follow_request.target_account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:reject])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:reject])
 
     object = Ox::Element.new('activity:object')
     object << author(follow_request.account)
 
-    append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
+    append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
 
     inner_object = author(follow_request.target_account)
     inner_object.value = 'activity:object'
@@ -212,14 +215,14 @@ class OStatus::AtomSerializer
 
     description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(follow.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:unfollow])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfollow])
 
     object = author(follow.target_account)
     object.value = 'activity:object'
@@ -234,13 +237,13 @@ class OStatus::AtomSerializer
 
     description = "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
     append_element(entry, 'title', description)
 
     entry << author(block.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:block])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:block])
 
     object = author(block.target_account)
     object.value = 'activity:object'
@@ -255,13 +258,13 @@ class OStatus::AtomSerializer
 
     description = "#{block.account.acct} no longer blocks #{block.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
     append_element(entry, 'title', description)
 
     entry << author(block.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:unblock])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unblock])
 
     object = author(block.target_account)
     object.value = 'activity:object'
@@ -276,18 +279,18 @@ class OStatus::AtomSerializer
 
     description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(favourite.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:favorite])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:favorite])
 
     entry << object(favourite.status)
 
-    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
+    append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
 
     entry
   end
@@ -298,18 +301,18 @@ class OStatus::AtomSerializer
 
     description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(favourite.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:unfavorite])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfavorite])
 
     entry << object(favourite.status)
 
-    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
+    append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
 
     entry
   end
@@ -329,28 +332,30 @@ class OStatus::AtomSerializer
 
   def conversation_uri(conversation)
     return conversation.uri if conversation.uri?
-    TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation')
+    OStatus::TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation')
   end
 
   def add_namespaces(parent)
-    parent['xmlns']          = TagManager::XMLNS
-    parent['xmlns:thr']      = TagManager::THR_XMLNS
-    parent['xmlns:activity'] = TagManager::AS_XMLNS
-    parent['xmlns:poco']     = TagManager::POCO_XMLNS
-    parent['xmlns:media']    = TagManager::MEDIA_XMLNS
-    parent['xmlns:ostatus']  = TagManager::OS_XMLNS
-    parent['xmlns:mastodon'] = TagManager::MTDN_XMLNS
+    parent['xmlns']          = OStatus::TagManager::XMLNS
+    parent['xmlns:thr']      = OStatus::TagManager::THR_XMLNS
+    parent['xmlns:activity'] = OStatus::TagManager::AS_XMLNS
+    parent['xmlns:poco']     = OStatus::TagManager::POCO_XMLNS
+    parent['xmlns:media']    = OStatus::TagManager::MEDIA_XMLNS
+    parent['xmlns:ostatus']  = OStatus::TagManager::OS_XMLNS
+    parent['xmlns:mastodon'] = OStatus::TagManager::MTDN_XMLNS
   end
 
   def serialize_status_attributes(entry, status)
+    append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local?
+
     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.each do |mentioned|
-      append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:person], href: TagManager.instance.uri_for(mentioned.account))
+      append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
     end
 
-    append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:collection], href: TagManager::COLLECTIONS[:public]) if status.public_visibility?
+    append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:collection], href: OStatus::TagManager::COLLECTIONS[:public]) if status.public_visibility?
 
     status.tags.each do |tag|
       append_element(entry, 'category', nil, term: tag.name)
@@ -363,5 +368,9 @@ class OStatus::AtomSerializer
     end
 
     append_element(entry, 'mastodon:scope', status.visibility)
+
+    status.emojis.each do |emoji|
+      append_element(entry, 'link', nil, rel: :emoji, href: full_asset_url(emoji.image.url), name: emoji.shortcode)
+    end
   end
 end
diff --git a/app/lib/ostatus/tag_manager.rb b/app/lib/ostatus/tag_manager.rb
new file mode 100644
index 000000000..4f4501312
--- /dev/null
+++ b/app/lib/ostatus/tag_manager.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+class OStatus::TagManager
+  include Singleton
+  include RoutingHelper
+
+  VERBS = {
+    post:           'http://activitystrea.ms/schema/1.0/post',
+    share:          'http://activitystrea.ms/schema/1.0/share',
+    favorite:       'http://activitystrea.ms/schema/1.0/favorite',
+    unfavorite:     'http://activitystrea.ms/schema/1.0/unfavorite',
+    delete:         'http://activitystrea.ms/schema/1.0/delete',
+    follow:         'http://activitystrea.ms/schema/1.0/follow',
+    request_friend: 'http://activitystrea.ms/schema/1.0/request-friend',
+    authorize:      'http://activitystrea.ms/schema/1.0/authorize',
+    reject:         'http://activitystrea.ms/schema/1.0/reject',
+    unfollow:       'http://ostatus.org/schema/1.0/unfollow',
+    block:          'http://mastodon.social/schema/1.0/block',
+    unblock:        'http://mastodon.social/schema/1.0/unblock',
+  }.freeze
+
+  TYPES = {
+    activity:   'http://activitystrea.ms/schema/1.0/activity',
+    note:       'http://activitystrea.ms/schema/1.0/note',
+    comment:    'http://activitystrea.ms/schema/1.0/comment',
+    person:     'http://activitystrea.ms/schema/1.0/person',
+    collection: 'http://activitystrea.ms/schema/1.0/collection',
+    group:      'http://activitystrea.ms/schema/1.0/group',
+  }.freeze
+
+  COLLECTIONS = {
+    public: 'http://activityschema.org/collection/public',
+  }.freeze
+
+  XMLNS       = 'http://www.w3.org/2005/Atom'
+  MEDIA_XMLNS = 'http://purl.org/syndication/atommedia'
+  AS_XMLNS    = 'http://activitystrea.ms/spec/1.0/'
+  THR_XMLNS   = 'http://purl.org/syndication/thread/1.0'
+  POCO_XMLNS  = 'http://portablecontacts.net/spec/1.0'
+  DFRN_XMLNS  = 'http://purl.org/macgirvin/dfrn/1.0'
+  OS_XMLNS    = 'http://ostatus.org/schema/1.0'
+  MTDN_XMLNS  = 'http://mastodon.social/schema/1.0'
+
+  def unique_tag(date, id, type)
+    "tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}"
+  end
+
+  def unique_tag_to_local_id(tag, expected_type)
+    return nil unless local_id?(tag)
+
+    if ActivityPub::TagManager.instance.local_uri?(tag)
+      ActivityPub::TagManager.instance.uri_to_local_id(tag)
+    else
+      matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
+      return matches[1] unless matches.nil?
+    end
+  end
+
+  def local_id?(id)
+    id.start_with?("tag:#{Rails.configuration.x.local_domain}") || ActivityPub::TagManager.instance.local_uri?(id)
+  end
+
+  def uri_for(target)
+    return target.uri if target.respond_to?(:local?) && !target.local?
+
+    case target.object_type
+    when :person
+      account_url(target)
+    when :note, :comment, :activity
+      target.uri || unique_tag(target.created_at, target.id, 'Status')
+    end
+  end
+end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index e73c5ac20..b083edaf7 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -12,19 +12,27 @@ class Request
     @headers = {}
 
     set_common_headers!
+    set_digest! if options.key?(:body)
   end
 
-  def on_behalf_of(account)
+  def on_behalf_of(account, key_id_format = :acct)
     raise ArgumentError unless account.local?
-    @account = account
+
+    @account       = account
+    @key_id_format = key_id_format
+
+    self
   end
 
   def add_headers(new_headers)
     @headers.merge!(new_headers)
+    self
   end
 
   def perform
     http_client.headers(headers).public_send(@verb, @url.to_s, @options)
+  rescue => e
+    raise e.class, "#{e.message} on #{@url}"
   end
 
   def headers
@@ -40,8 +48,11 @@ class Request
     @headers['Date']         = Time.now.utc.httpdate
   end
 
+  def set_digest!
+    @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
+  end
+
   def signature
-    key_id    = @account.to_webfinger_s
     algorithm = 'rsa-sha256'
     signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
 
@@ -60,6 +71,15 @@ class Request
     @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
   end
 
+  def key_id
+    case @key_id_format
+    when :acct
+      @account.to_webfinger_s
+    when :uri
+      [ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join
+    end
+  end
+
   def timeout
     { write: 10, connect: 10, read: 10 }
   end
diff --git a/app/lib/stream_entry_finder.rb b/app/lib/status_finder.rb
index 0ea33229c..4d1aed297 100644
--- a/app/lib/stream_entry_finder.rb
+++ b/app/lib/status_finder.rb
@@ -1,20 +1,22 @@
 # frozen_string_literal: true
 
-class StreamEntryFinder
+class StatusFinder
   attr_reader :url
 
   def initialize(url)
     @url = url
   end
 
-  def stream_entry
+  def status
     verify_action!
 
+    raise ActiveRecord::RecordNotFound unless TagManager.instance.local_url?(url)
+
     case recognized_params[:controller]
     when 'stream_entries'
-      StreamEntry.find(recognized_params[:id])
+      StreamEntry.find(recognized_params[:id]).status
     when 'statuses'
-      Status.find(recognized_params[:id]).stream_entry
+      Status.find(recognized_params[:id])
     else
       raise ActiveRecord::RecordNotFound
     end
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 5f87a2a48..fb364cb98 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -6,57 +6,6 @@ class TagManager
   include Singleton
   include RoutingHelper
 
-  VERBS = {
-    post:           'http://activitystrea.ms/schema/1.0/post',
-    share:          'http://activitystrea.ms/schema/1.0/share',
-    favorite:       'http://activitystrea.ms/schema/1.0/favorite',
-    unfavorite:     'http://activitystrea.ms/schema/1.0/unfavorite',
-    delete:         'http://activitystrea.ms/schema/1.0/delete',
-    follow:         'http://activitystrea.ms/schema/1.0/follow',
-    request_friend: 'http://activitystrea.ms/schema/1.0/request-friend',
-    authorize:      'http://activitystrea.ms/schema/1.0/authorize',
-    reject:         'http://activitystrea.ms/schema/1.0/reject',
-    unfollow:       'http://ostatus.org/schema/1.0/unfollow',
-    block:          'http://mastodon.social/schema/1.0/block',
-    unblock:        'http://mastodon.social/schema/1.0/unblock',
-  }.freeze
-
-  TYPES = {
-    activity:   'http://activitystrea.ms/schema/1.0/activity',
-    note:       'http://activitystrea.ms/schema/1.0/note',
-    comment:    'http://activitystrea.ms/schema/1.0/comment',
-    person:     'http://activitystrea.ms/schema/1.0/person',
-    collection: 'http://activitystrea.ms/schema/1.0/collection',
-    group:      'http://activitystrea.ms/schema/1.0/group',
-  }.freeze
-
-  COLLECTIONS = {
-    public: 'http://activityschema.org/collection/public',
-  }.freeze
-
-  XMLNS       = 'http://www.w3.org/2005/Atom'
-  MEDIA_XMLNS = 'http://purl.org/syndication/atommedia'
-  AS_XMLNS    = 'http://activitystrea.ms/spec/1.0/'
-  THR_XMLNS   = 'http://purl.org/syndication/thread/1.0'
-  POCO_XMLNS  = 'http://portablecontacts.net/spec/1.0'
-  DFRN_XMLNS  = 'http://purl.org/macgirvin/dfrn/1.0'
-  OS_XMLNS    = 'http://ostatus.org/schema/1.0'
-  MTDN_XMLNS  = 'http://mastodon.social/schema/1.0'
-
-  def unique_tag(date, id, type)
-    "tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}"
-  end
-
-  def unique_tag_to_local_id(tag, expected_type)
-    return nil unless local_id?(tag)
-    matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
-    return matches[1] unless matches.nil?
-  end
-
-  def local_id?(id)
-    id.start_with?("tag:#{Rails.configuration.x.local_domain}")
-  end
-
   def web_domain?(domain)
     domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.web_domain).zero?
   end
@@ -82,18 +31,7 @@ class TagManager
   def local_url?(url)
     uri    = Addressable::URI.parse(url).normalize
     domain = uri.host + (uri.port ? ":#{uri.port}" : '')
-    TagManager.instance.local_domain?(domain)
-  end
-
-  def uri_for(target)
-    return target.uri if target.respond_to?(:local?) && !target.local?
-
-    case target.object_type
-    when :person
-      account_url(target)
-    when :note, :comment, :activity
-      unique_tag(target.created_at, target.id, 'Status')
-    end
+    TagManager.instance.web_domain?(domain)
   end
 
   def url_for(target)
diff --git a/app/lib/themes.rb b/app/lib/themes.rb
new file mode 100644
index 000000000..2dd188297
--- /dev/null
+++ b/app/lib/themes.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'singleton'
+require 'yaml'
+
+class Themes
+  include Singleton
+
+  def initialize
+    result = Hash.new
+    Dir.glob(Rails.root.join('app', 'javascript', 'themes', '*', 'theme.yml')) do |path|
+      data = YAML.load_file(path)
+      if data['pack'] && data['name']
+        result[data['name']] = data
+      end
+    end
+    @conf = result
+  end
+
+  def names
+    @conf.keys
+  end
+end
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 62046ed72..3b156b98c 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -25,6 +25,7 @@ class UserSettingsDecorator
     user.settings['auto_play_gif'] = auto_play_gif_preference
     user.settings['system_font_ui'] = system_font_ui_preference
     user.settings['noindex'] = noindex_preference
+    user.settings['theme'] = theme_preference
   end
 
   def merged_notification_emails
@@ -67,6 +68,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_noindex'
   end
 
+  def theme_preference
+    settings['setting_theme']
+  end
+  
   def boolean_cast_setting(key)
     settings[key] == '1'
   end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 12b92bf45..80c9d8ccf 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -8,6 +8,7 @@ class NotificationMailer < ApplicationMailer
     @status = notification.target_status
 
     locale_for_account(@me) do
+      thread_by_conversation(@status.conversation)
       mail to: @me.user.email, subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
     end
   end
@@ -27,6 +28,7 @@ class NotificationMailer < ApplicationMailer
     @status  = notification.target_status
 
     locale_for_account(@me) do
+      thread_by_conversation(@status.conversation)
       mail to: @me.user.email, subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
     end
   end
@@ -37,6 +39,7 @@ class NotificationMailer < ApplicationMailer
     @status  = notification.target_status
 
     locale_for_account(@me) do
+      thread_by_conversation(@status.conversation)
       mail to: @me.user.email, subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
     end
   end
@@ -67,4 +70,13 @@ class NotificationMailer < ApplicationMailer
            )
     end
   end
+
+  private
+
+  def thread_by_conversation(conversation)
+    return if conversation.nil?
+    msg_id = "<conversation-#{conversation.id}.#{conversation.created_at.strftime('%Y-%m-%d')}@#{Rails.configuration.x.local_domain}>"
+    headers['In-Reply-To'] = msg_id
+    headers['References'] = msg_id
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index e217733f5..de7998db4 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -52,7 +52,8 @@ class Account < ApplicationRecord
   include AccountInteractions
   include Attachmentable
   include Remotable
-  include EmojiHelper
+
+  MAX_NOTE_LENGTH = 500
 
   enum protocol: [:ostatus, :activitypub]
 
@@ -77,6 +78,10 @@ class Account < ApplicationRecord
   has_many :mentions, inverse_of: :account, dependent: :destroy
   has_many :notifications, inverse_of: :account, dependent: :destroy
 
+  # Pinned statuses
+  has_many :status_pins, inverse_of: :account, dependent: :destroy
+  has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
@@ -91,7 +96,7 @@ class Account < ApplicationRecord
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where(followers_count: 0) }
   scope :with_followers, -> { where('followers_count > 0') }
-  scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
+  scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
   scope :partitioned, -> { order('row_number() over (partition by domain)') }
   scope :silenced, -> { where(silenced: true) }
   scope :suspended, -> { where(suspended: true) }
@@ -100,11 +105,13 @@ class Account < ApplicationRecord
   scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
   scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
+  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
 
   delegate :email,
            :current_sign_in_ip,
            :current_sign_in_at,
            :confirmed?,
+           :admin?,
            :locale,
            to: :user,
            prefix: true,
@@ -133,11 +140,11 @@ class Account < ApplicationRecord
   end
 
   def keypair
-    OpenSSL::PKey::RSA.new(private_key || public_key)
+    @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
 
   def subscription(webhook_url)
-    OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
+    @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
   end
 
   def save_with_optional_media!
@@ -167,10 +174,18 @@ class Account < ApplicationRecord
   end
 
   class << self
+    def readonly_attributes
+      super - %w(statuses_count following_count followers_count)
+    end
+
     def domains
       reorder(nil).pluck('distinct accounts.domain')
     end
 
+    def inboxes
+      reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
+    end
+
     def triadic_closures(account, limit: 5, offset: 0)
       sql = <<-SQL.squish
         WITH first_degree AS (
@@ -255,15 +270,12 @@ class Account < ApplicationRecord
   def prepare_contents
     display_name&.strip!
     note&.strip!
-
-    self.display_name = emojify(display_name)
-    self.note         = emojify(note)
   end
 
   def generate_keys
     return unless local?
 
-    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048)
+    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
     self.private_key = keypair.to_pem
     self.public_key  = keypair.public_key.to_pem
   end
@@ -279,7 +291,7 @@ class Account < ApplicationRecord
         note_without_metadata = note[(idx + YAML_END.length) .. -1]
       end
     end
-    if note_without_metadata.mb_chars.grapheme_length > 500
+    if note_without_metadata.mb_chars.grapheme_length > MAX_NOTE_LENGTH
       errors.add(:note, "can't be longer than 500 graphemes")
     end
   end
diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb
index a6527a85b..8a5c9a22c 100644
--- a/app/models/concerns/account_avatar.rb
+++ b/app/models/concerns/account_avatar.rb
@@ -8,7 +8,7 @@ module AccountAvatar
   class_methods do
     def avatar_styles(file)
       styles = { original: '120x120#' }
-      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb
index 4ba9212a2..aff2aa3f9 100644
--- a/app/models/concerns/account_header.rb
+++ b/app/models/concerns/account_header.rb
@@ -8,7 +8,7 @@ module AccountHeader
   class_methods do
     def header_styles(file)
       styles = { original: '700x335#' }
-      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 9ffed2910..0afdebf89 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -17,7 +17,11 @@ module AccountInteractions
     end
 
     def muting_map(target_account_ids, account_id)
-      follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+      Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
+        mapping[mute.target_account_id] = {
+          notifications: mute.hide_notifications?
+        }
+      end
     end
 
     def requested_map(target_account_ids, account_id)
@@ -70,8 +74,14 @@ module AccountInteractions
     block_relationships.find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account)
-    mute_relationships.find_or_create_by!(target_account: other_account)
+  def mute!(other_account, notifications: nil)
+    notifications = true if notifications.nil?
+    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
+    # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
+    if mute.hide_notifications? != notifications
+      mute.hide_notifications = notifications
+      mute.save!
+    end
   end
 
   def mute_conversation!(conversation)
@@ -127,6 +137,10 @@ module AccountInteractions
     conversation_mutes.where(conversation: conversation).exists?
   end
 
+  def muting_notifications?(other_account)
+    mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
@@ -138,4 +152,8 @@ module AccountInteractions
   def reblogged?(status)
     status.proper.reblogs.where(account: self).exists?
   end
+
+  def pinned?(status)
+    status_pins.where(status: status).exists?
+  end
 end
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 1bd87a642..990035b34 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -10,6 +10,8 @@ module Remotable
       alt_method_name = "reset_#{attachment_name}!".to_sym
 
       define_method method_name do |url|
+        return if url.blank?
+
         begin
           parsed_url = Addressable::URI.parse(url).normalize
         rescue Addressable::URI::InvalidURIError
@@ -25,9 +27,11 @@ module Remotable
 
           matches  = response.headers['content-disposition']&.match(/filename="([^"]*)"/)
           filename = matches.nil? ? parsed_url.path.split('/').last : matches[1]
+          basename = SecureRandom.hex(8)
+          extname  = File.extname(filename)
 
           send("#{attachment_name}=", StringIO.new(response.to_s))
-          send("#{attachment_name}_file_name=", filename)
+          send("#{attachment_name}_file_name=", basename + extname)
 
           self[attribute_name] = url if has_attribute?(attribute_name)
         rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError => e
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
new file mode 100644
index 000000000..e80c58155
--- /dev/null
+++ b/app/models/custom_emoji.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: custom_emojis
+#
+#  id                 :integer          not null, primary key
+#  shortcode          :string           default(""), not null
+#  domain             :string
+#  image_file_name    :string
+#  image_content_type :string
+#  image_file_size    :integer
+#  image_updated_at   :datetime
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
+#
+
+class CustomEmoji < ApplicationRecord
+  SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
+
+  SCAN_RE = /(?<=[^[:alnum:]:]|\n|^)
+    :(#{SHORTCODE_RE_FRAGMENT}):
+    (?=[^[:alnum:]:]|$)/x
+
+  has_attached_file :image
+
+  validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes }
+  validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
+
+  scope :local, -> { where(domain: nil) }
+
+  include Remotable
+
+  class << self
+    def from_text(text, domain)
+      return [] if text.blank?
+
+      shortcodes = text.scan(SCAN_RE).map(&:first).uniq
+
+      return [] if shortcodes.empty?
+
+      where(shortcode: shortcodes, domain: domain)
+    end
+  end
+end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index c3a04ba65..2b148c82b 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -24,6 +24,8 @@ class Form::AdminSettings
     :open_deletion=,
     :timeline_preview,
     :timeline_preview=,
+    :bootstrap_timeline_accounts,
+    :bootstrap_timeline_accounts=,
     to: Setting
   )
 end
diff --git a/app/models/import.rb b/app/models/import.rb
index 815e02589..4656c3af6 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -28,4 +28,5 @@ class Import < ApplicationRecord
 
   has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
   validates_attachment_content_type :data, content_type: FILE_TYPES
+  validates_attachment_presence :data
 end
diff --git a/app/models/instance_filter.rb b/app/models/instance_filter.rb
new file mode 100644
index 000000000..5073cf1fa
--- /dev/null
+++ b/app/models/instance_filter.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class InstanceFilter
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Account.remote.by_domain_accounts
+    params.each do |key, value|
+      scope.merge!(scope_for(key, value)) if value.present?
+    end
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'domain_name'
+      Account.matches_domain(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 1e8c6d00a..e4a974f96 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -25,6 +25,9 @@ class MediaAttachment < ApplicationRecord
 
   enum type: [:image, :gifv, :video, :unknown]
 
+  IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze
+  VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v'].freeze
+
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
 
@@ -56,15 +59,21 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
 
-  scope :attached, -> { where.not(status_id: nil) }
+  scope :attached,   -> { where.not(status_id: nil) }
   scope :unattached, -> { where(status_id: nil) }
-  scope :local, -> { where(remote_url: '') }
+  scope :local,      -> { where(remote_url: '') }
+  scope :remote,     -> { where.not(remote_url: '') }
+
   default_scope { order(id: :asc) }
 
   def local?
     remote_url.blank?
   end
 
+  def needs_redownload?
+    file.blank? && remote_url.present?
+  end
+
   def to_param
     shortcode
   end
@@ -142,9 +151,11 @@ class MediaAttachment < ApplicationRecord
 
   def populate_meta
     meta = {}
+
     file.queued_for_write.each do |style, file|
       begin
         geo = Paperclip::Geometry.from_file file
+
         meta[style] = {
           width: geo.width.to_i,
           height: geo.height.to_i,
@@ -155,6 +166,7 @@ class MediaAttachment < ApplicationRecord
         meta[style] = {}
       end
     end
+
     meta
   end
 
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 00e5661a7..0d597a275 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -3,11 +3,12 @@
 #
 # Table name: mutes
 #
-#  id                :integer          not null, primary key
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
-#  created_at        :datetime         not null
-#  updated_at        :datetime         not null
+#  id                 :integer          not null, primary key
+#  account_id         :integer          not null
+#  target_account_id  :integer          not null
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
+#  hide_notifications :boolean          default(TRUE), not null
 #
 
 class Mute < ApplicationRecord
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index c334c48aa..e2bf65d94 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -4,16 +4,13 @@
 # Table name: preview_cards
 #
 #  id                 :integer          not null, primary key
-#  status_id          :integer
 #  url                :string           default(""), not null
-#  title              :string
-#  description        :string
+#  title              :string           default(""), not null
+#  description        :string           default(""), not null
 #  image_file_name    :string
 #  image_content_type :string
 #  image_file_size    :integer
 #  image_updated_at   :datetime
-#  created_at         :datetime         not null
-#  updated_at         :datetime         not null
 #  type               :integer          default("link"), not null
 #  html               :text             default(""), not null
 #  author_name        :string           default(""), not null
@@ -22,6 +19,8 @@
 #  provider_url       :string           default(""), not null
 #  width              :integer          default(0), not null
 #  height             :integer          default(0), not null
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
 #
 
 class PreviewCard < ApplicationRecord
@@ -31,21 +30,37 @@ class PreviewCard < ApplicationRecord
 
   enum type: [:link, :photo, :video, :rich]
 
-  belongs_to :status
+  has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, styles: { original: '280x280>' }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
   include Remotable
 
-  validates :url, presence: true
+  validates :url, presence: true, uniqueness: true
   validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :image, less_than: 1.megabytes
 
+  before_save :extract_dimensions, if: :link?
+
   def save_with_optional_image!
     save!
   rescue ActiveRecord::RecordInvalid
     self.image = nil
     save!
   end
+
+  private
+
+  def extract_dimensions
+    file = image.queued_for_write[:original]
+
+    return if file.nil?
+
+    geo         = Paperclip::Geometry.from_file(file)
+    self.width  = geo.width.to_i
+    self.height = geo.height.to_i
+  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
+    nil
+  end
 end
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 8366d43c5..c3f867743 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -42,7 +42,7 @@ class RemoteFollow
 
   def acct_resource
     @_acct_resource ||= Goldfinger.finger("acct:#{acct}")
-  rescue Goldfinger::Error
+  rescue Goldfinger::Error, HTTP::ConnectionError
     nil
   end
 
diff --git a/app/models/remote_profile.rb b/app/models/remote_profile.rb
index 93c759930..613911c57 100644
--- a/app/models/remote_profile.rb
+++ b/app/models/remote_profile.rb
@@ -10,11 +10,11 @@ class RemoteProfile
   end
 
   def root
-    @root ||= document.at_xpath('/atom:feed|/atom:entry', atom: TagManager::XMLNS)
+    @root ||= document.at_xpath('/atom:feed|/atom:entry', atom: OStatus::TagManager::XMLNS)
   end
 
   def author
-    @author ||= root.at_xpath('./atom:author|./dfrn:owner', atom: TagManager::XMLNS, dfrn: TagManager::DFRN_XMLNS)
+    @author ||= root.at_xpath('./atom:author|./dfrn:owner', atom: OStatus::TagManager::XMLNS, dfrn: OStatus::TagManager::DFRN_XMLNS)
   end
 
   def hub_link
@@ -22,15 +22,15 @@ class RemoteProfile
   end
 
   def display_name
-    @display_name ||= author.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS)&.content
+    @display_name ||= author.at_xpath('./poco:displayName', poco: OStatus::TagManager::POCO_XMLNS)&.content
   end
 
   def note
-    @note ||= author.at_xpath('./atom:summary|./poco:note', atom: TagManager::XMLNS, poco: TagManager::POCO_XMLNS)&.content
+    @note ||= author.at_xpath('./atom:summary|./poco:note', atom: OStatus::TagManager::XMLNS, poco: OStatus::TagManager::POCO_XMLNS)&.content
   end
 
   def scope
-    @scope ||= author.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content
+    @scope ||= author.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content
   end
 
   def avatar
@@ -48,6 +48,6 @@ class RemoteProfile
   private
 
   def link_href_from_xml(xml, type)
-    xml.at_xpath(%(./atom:link[@rel="#{type}"]/@href), atom: TagManager::XMLNS)&.content
+    xml.at_xpath(%(./atom:link[@rel="#{type}"]/@href), atom: OStatus::TagManager::XMLNS)&.content
   end
 end
diff --git a/app/models/report.rb b/app/models/report.rb
index 4d2552d30..479aa17bb 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -22,6 +22,8 @@ class Report < ApplicationRecord
   scope :unresolved, -> { where(action_taken: false) }
   scope :resolved,   -> { where(action_taken: true) }
 
+  validates :comment, length: { maximum: 1000 }
+
   def statuses
     Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
   end
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 7eb16af8f..c1645223b 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -25,6 +25,7 @@
 #
 
 class SessionActivation < ApplicationRecord
+  belongs_to :user, inverse_of: :session_activations, required: true
   belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
   belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy
 
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
new file mode 100644
index 000000000..8ffdc8313
--- /dev/null
+++ b/app/models/site_upload.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: site_uploads
+#
+#  id                :integer          not null, primary key
+#  var               :string           default(""), not null
+#  file_file_name    :string
+#  file_content_type :string
+#  file_file_size    :integer
+#  file_updated_at   :datetime
+#  meta              :json
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class SiteUpload < ApplicationRecord
+  has_attached_file :file
+
+  validates_attachment_content_type :file, content_type: /\Aimage\/.*\z/
+  validates :var, presence: true, uniqueness: true
+
+  before_save :set_meta
+  after_commit :clear_cache
+
+  def cache_key
+    "site_uploads/#{var}"
+  end
+
+  private
+
+  def set_meta
+    tempfile = file.queued_for_write[:original]
+
+    return if tempfile.nil?
+
+    geometry  = Paperclip::Geometry.from_file(tempfile)
+    self.meta = { width: geometry.width.to_i, height: geometry.height.to_i }
+  end
+
+  def clear_cache
+    Rails.cache.delete(cache_key)
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 24eaf7071..ea4c097bf 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -22,6 +22,7 @@
 #  reblogs_count          :integer          default(0), not null
 #  language               :string
 #  conversation_id        :integer
+#  local                  :boolean
 #
 
 class Status < ApplicationRecord
@@ -29,7 +30,6 @@ class Status < ApplicationRecord
   include Streamable
   include Cacheable
   include StatusThreadingConcern
-  include EmojiHelper
 
   enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
 
@@ -47,12 +47,14 @@ class Status < ApplicationRecord
   has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy
   has_many :media_attachments, dependent: :destroy
+
   has_and_belongs_to_many :tags
+  has_and_belongs_to_many :preview_cards
 
   has_one :notification, as: :activity, dependent: :destroy
-  has_one :preview_card, dependent: :destroy
+  has_one :stream_entry, as: :activity, inverse_of: :status
 
-  validates :uri, uniqueness: true, unless: :local?
+  validates :uri, uniqueness: true, presence: true, unless: :local?
   validates :text, presence: true, unless: :reblog?
   validates_with StatusLengthValidator
   validates :reblog, uniqueness: { scope: :account }, if: :reblog?
@@ -60,14 +62,13 @@ class Status < ApplicationRecord
   default_scope { recent }
 
   scope :recent, -> { reorder(id: :desc) }
-  scope :remote, -> { where.not(uri: nil) }
-  scope :local, -> { where(uri: nil) }
+  scope :remote, -> { where(local: false).or(where.not(uri: nil)) }
+  scope :local,  -> { where(local: true).or(where(uri: nil)) }
 
   scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
   scope :with_public_visibility, -> { where(visibility: :public) }
   scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
-  scope :local_only, -> { left_outer_joins(:account).where(accounts: { domain: nil }) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: false }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: true }) }
   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
@@ -82,7 +83,7 @@ class Status < ApplicationRecord
   end
 
   def local?
-    uri.nil?
+    attributes['local'] || uri.nil?
   end
 
   def reblog?
@@ -90,7 +91,11 @@ class Status < ApplicationRecord
   end
 
   def verb
-    reblog? ? :share : :post
+    if destroyed?
+      :delete
+    else
+      reblog? ? :share : :post
+    end
   end
 
   def object_type
@@ -110,7 +115,11 @@ class Status < ApplicationRecord
   end
 
   def title
-    reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+    if destroyed?
+      "#{account.acct} deleted status"
+    else
+      reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+    end
   end
 
   def hidden?
@@ -121,15 +130,22 @@ class Status < ApplicationRecord
     !sensitive? && media_attachments.any?
   end
 
+  def emojis
+    CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
+  end
+
+  after_create :store_uri, if: :local?
+
   before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
   before_validation :set_visibility
   before_validation :set_conversation
   before_validation :set_sensitivity
+  before_validation :set_local
 
   class << self
     def not_in_filtered_languages(account)
-      where.not(language: account.filtered_languages)
+      where(language: nil).or where.not(language: account.filtered_languages)
     end
 
     def as_home_timeline(account)
@@ -164,6 +180,10 @@ class Status < ApplicationRecord
       ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
     end
 
+    def pins_map(status_ids, account_id)
+      StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h
+    end
+
     def reload_stale_associations!(cached_items)
       account_ids = []
 
@@ -203,7 +223,7 @@ class Status < ApplicationRecord
     private
 
     def timeline_scope(local_only = false)
-      starting_scope = local_only ? Status.local_only : Status
+      starting_scope = local_only ? Status.local : Status
       starting_scope
         .with_public_visibility
         .without_reblogs
@@ -239,12 +259,13 @@ class Status < ApplicationRecord
 
   private
 
+  def store_uri
+    update_attribute(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil?
+  end
+
   def prepare_contents
     text&.strip!
     spoiler_text&.strip!
-
-    self.text         = emojify(text)
-    self.spoiler_text = emojify(spoiler_text)
   end
 
   def set_reblog
@@ -278,4 +299,8 @@ class Status < ApplicationRecord
       thread.account_id
     end
   end
+
+  def set_local
+    self.local = account.local?
+  end
 end
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
new file mode 100644
index 000000000..a72c19750
--- /dev/null
+++ b/app/models/status_pin.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_pins
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  status_id  :integer          not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class StatusPin < ApplicationRecord
+  belongs_to :account, required: true
+  belongs_to :status, required: true
+
+  validates_with StatusPinValidator
+end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index bf643c1f9..14f1a140c 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -26,6 +26,7 @@ class Subscription < ApplicationRecord
 
   scope :confirmed, -> { where(confirmed: true) }
   scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) }
+  scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) }
   scope :active, -> { confirmed.future_expiration }
 
   def lease_seconds=(value)
diff --git a/app/models/user.rb b/app/models/user.rb
index 96a2d09b7..3bf069a31 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -46,6 +46,8 @@ class User < ApplicationRecord
   belongs_to :account, inverse_of: :user, required: true
   accepts_nested_attributes_for :account
 
+  has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
+
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
 
@@ -108,10 +110,25 @@ class User < ApplicationRecord
     settings.noindex
   end
 
+  def setting_theme
+    settings.theme
+  end
+
+  def token_for_app(a)
+    return nil if a.nil? || a.owner != self
+    Doorkeeper::AccessToken
+      .find_or_create_by(application_id: a.id, resource_owner_id: id) do |t|
+
+      t.scopes = a.scopes
+      t.expires_in = Doorkeeper.configuration.access_token_expires_in
+      t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled?
+    end
+  end
+
   def activate_session(request)
     session_activations.activate(session_id: SecureRandom.hex,
                                  user_agent: request.user_agent,
-                                 ip: request.ip).session_id
+                                 ip: request.remote_ip).session_id
   end
 
   def exclusive_session(id)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index e76f61278..cb15dfa37 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -13,59 +13,14 @@
 #
 
 require 'webpush'
-require_relative '../../models/setting'
 
 class Web::PushSubscription < ApplicationRecord
-  include RoutingHelper
-  include StreamEntriesHelper
-  include ActionView::Helpers::TranslationHelper
-  include ActionView::Helpers::SanitizeHelper
-
   has_one :session_activation
 
-  before_create :send_welcome_notification
-
   def push(notification)
-    name = display_name notification.from_account
-    title = title_str(name, notification)
-    body = body_str notification
-    dir = dir_str body
-    url = url_str notification
-    image = image_str notification
-    actions = actions_arr notification
-
-    access_token = actions.empty? ? nil : find_or_create_access_token(notification).token
-    nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text
-
-    # TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge
-    Webpush.payload_send(
-      message: JSON.generate(
-        title: title,
-        dir: dir,
-        image: image,
-        badge: full_asset_url('badge.png', skip_pipeline: true),
-        tag: notification.id,
-        timestamp: notification.created_at,
-        icon: notification.from_account.avatar_static_url,
-        data: {
-          content: decoder.decode(strip_tags(body)),
-          nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)),
-          url: url,
-          actions: actions,
-          access_token: access_token,
-          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
-        }
-      ),
-      endpoint: endpoint,
-      p256dh: key_p256dh,
-      auth: key_auth,
-      vapid: {
-        subject: "mailto:#{Setting.site_contact_email}",
-        private_key: Rails.configuration.x.vapid_private_key,
-        public_key: Rails.configuration.x.vapid_public_key,
-      },
-      ttl: 40 * 60 * 60 # 48 hours
-    )
+    I18n.with_locale(session_activation.user.locale || I18n.default_locale) do
+      push_payload(message_from(notification), 48.hours.seconds)
+    end
   end
 
   def pushable?(notification)
@@ -73,120 +28,47 @@ class Web::PushSubscription < ApplicationRecord
   end
 
   def as_payload
-    payload = {
-      id: id,
-      endpoint: endpoint,
-    }
-
+    payload = { id: id, endpoint: endpoint }
     payload[:alerts] = data['alerts'] if data && data.key?('alerts')
-
     payload
   end
 
-  private
-
-  def title_str(name, notification)
-    case notification.type
-    when :mention then translate('push_notifications.mention.title', name: name)
-    when :follow then translate('push_notifications.follow.title', name: name)
-    when :favourite then translate('push_notifications.favourite.title', name: name)
-    when :reblog then translate('push_notifications.reblog.title', name: name)
-    end
+  def access_token
+    find_or_create_access_token.token
   end
 
-  def body_str(notification)
-    case notification.type
-    when :mention then notification.target_status.text
-    when :follow then notification.from_account.note
-    when :favourite then notification.target_status.text
-    when :reblog then notification.target_status.text
-    end
-  end
-
-  def url_str(notification)
-    case notification.type
-    when :mention then web_url("statuses/#{notification.target_status.id}")
-    when :follow then web_url("accounts/#{notification.from_account.id}")
-    when :favourite then web_url("statuses/#{notification.target_status.id}")
-    when :reblog then web_url("statuses/#{notification.target_status.id}")
-    end
-  end
-
-  def actions_arr(notification)
-    actions =
-      case notification.type
-      when :mention then [
-        {
-          title: translate('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/#{notification.target_status.id}/favourite",
-        },
-      ]
-      else []
-      end
-
-    should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?)
-    can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden?
-
-    if should_hide
-      actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand')
-    end
-
-    if can_boost
-      actions << { title: translate('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/#{notification.target_status.id}/reblog" }
-    end
-
-    actions
-  end
-
-  def image_str(notification)
-    return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty?
-
-    full_asset_url(notification.target_status.media_attachments.first.file.url(:small))
-  end
+  private
 
-  def dir_str(body)
-    rtl?(body) ? 'rtl' : 'ltr'
-  end
+  def push_payload(message, ttl = 5.minutes.seconds)
+    # TODO: Make sure that the payload does not
+    # exceed 4KB - Webpush::PayloadTooLarge
 
-  def send_welcome_notification
     Webpush.payload_send(
-      message: JSON.generate(
-        title: translate('push_notifications.subscribed.title'),
-        icon: full_asset_url('android-chrome-192x192.png', skip_pipeline: true),
-        badge: full_asset_url('badge.png', skip_pipeline: true),
-        data: {
-          content: translate('push_notifications.subscribed.body'),
-          actions: [],
-          url: web_url('notifications'),
-          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
-        }
-      ),
+      message: Oj.dump(message),
       endpoint: endpoint,
       p256dh: key_p256dh,
       auth: key_auth,
+      ttl: ttl,
       vapid: {
-        subject: "mailto:#{Setting.site_contact_email}",
+        subject: "mailto:#{::Setting.site_contact_email}",
         private_key: Rails.configuration.x.vapid_private_key,
         public_key: Rails.configuration.x.vapid_public_key,
-      },
-      ttl: 5 * 60 # 5 minutes
+      }
     )
   end
 
-  def find_or_create_access_token(notification)
+  def message_from(notification)
+    serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription)
+    serializable_resource.as_json
+  end
+
+  def find_or_create_access_token
     Doorkeeper::AccessToken.find_or_create_for(
       Doorkeeper::Application.find_by(superapp: true),
-      notification.account.user.id,
+      session_activation.user_id,
       Doorkeeper::OAuth::Scopes.from_string('read write follow'),
       Doorkeeper.configuration.access_token_expires_in,
       Doorkeeper.configuration.refresh_token_enabled?
     )
   end
-
-  def decoder
-    @decoder ||= HTMLEntities.new
-  end
 end
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index 657807863..a30558bac 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -4,12 +4,12 @@ class AccountRelationshipsPresenter
   attr_reader :following, :followed_by, :blocking,
               :muting, :requested, :domain_blocking
 
-  def initialize(account_ids, current_account_id)
-    @following       = Account.following_map(account_ids, current_account_id)
-    @followed_by     = Account.followed_by_map(account_ids, current_account_id)
-    @blocking        = Account.blocking_map(account_ids, current_account_id)
-    @muting          = Account.muting_map(account_ids, current_account_id)
-    @requested       = Account.requested_map(account_ids, current_account_id)
-    @domain_blocking = Account.domain_blocking_map(account_ids, current_account_id)
+  def initialize(account_ids, current_account_id, options = {})
+    @following       = Account.following_map(account_ids, current_account_id).merge(options[:following_map] || {})
+    @followed_by     = Account.followed_by_map(account_ids, current_account_id).merge(options[:followed_by_map] || {})
+    @blocking        = Account.blocking_map(account_ids, current_account_id).merge(options[:blocking_map] || {})
+    @muting          = Account.muting_map(account_ids, current_account_id).merge(options[:muting_map] || {})
+    @requested       = Account.requested_map(account_ids, current_account_id).merge(options[:requested_map] || {})
+    @domain_blocking = Account.domain_blocking_map(account_ids, current_account_id).merge(options[:domain_blocking_map] || {})
   end
 end
diff --git a/app/presenters/activitypub/collection_presenter.rb b/app/presenters/activitypub/collection_presenter.rb
index 631d87cd0..39657276f 100644
--- a/app/presenters/activitypub/collection_presenter.rb
+++ b/app/presenters/activitypub/collection_presenter.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 class ActivityPub::CollectionPresenter < ActiveModelSerializers::Model
-  attributes :id, :type, :size, :items
+  attributes :id, :type, :size, :items, :part_of, :first, :next, :prev
 end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index 9507aad4a..70c496be8 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
 class InitialStatePresenter < ActiveModelSerializers::Model
-  attributes :settings, :push_subscription, :token, :current_account, :admin
+  attributes :settings, :push_subscription, :token,
+             :current_account, :admin, :text
 end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 4de6b5e0d..1c08fb3bc 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -21,7 +21,7 @@ class InstancePresenter
   end
 
   def status_count
-    Rails.cache.fetch('local_status_count') { Status.local.count }
+    Rails.cache.fetch('local_status_count') { Account.local.sum(:statuses_count) }
   end
 
   def domain_count
@@ -40,4 +40,12 @@ class InstancePresenter
       ''
     end
   end
+
+  def source_url
+    Mastodon::Version.source_url
+  end
+
+  def thumbnail
+    @thumbnail ||= Rails.cache.fetch('site_uploads/thumbnail') { SiteUpload.find_by(var: 'thumbnail') }
+  end
 end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 03294015f..bc3887a44 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -1,19 +1,24 @@
 # frozen_string_literal: true
 
 class StatusRelationshipsPresenter
-  attr_reader :reblogs_map, :favourites_map, :mutes_map
+  attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map
 
-  def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {})
+  def initialize(statuses, current_account_id = nil, options = {})
     if current_account_id.nil?
       @reblogs_map    = {}
       @favourites_map = {}
       @mutes_map      = {}
+      @pins_map       = {}
     else
-      status_ids       = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
-      conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq
-      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(reblogs_map)
-      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(favourites_map)
-      @mutes_map       = Status.mutes_map(conversation_ids, current_account_id).merge(mutes_map)
+      statuses            = statuses.compact
+      status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
+      conversation_ids    = statuses.map(&:conversation_id).compact.uniq
+      pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }.map(&:id)
+
+      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
+      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
+      @mutes_map       = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
+      @pins_map        = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
     end
   end
 end
diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb
index ce900bc78..3e23591a5 100644
--- a/app/serializers/activitypub/accept_follow_serializer.rb
+++ b/app/serializers/activitypub/accept_follow_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join
+  end
+
   def type
     'Accept'
   end
diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb
index 69e2160c5..b252e008b 100644
--- a/app/serializers/activitypub/activity_serializer.rb
+++ b/app/serializers/activitypub/activity_serializer.rb
@@ -1,22 +1,26 @@
 # frozen_string_literal: true
 
 class ActivityPub::ActivitySerializer < ActiveModel::Serializer
-  attributes :id, :type, :actor, :to, :cc
+  attributes :id, :type, :actor, :published, :to, :cc
 
   has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer
 
   def id
-    [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join
+    [ActivityPub::TagManager.instance.activity_uri_for(object)].join
   end
 
   def type
-    object.reblog? ? 'Announce' : 'Create'
+    announce? ? 'Announce' : 'Create'
   end
 
   def actor
     ActivityPub::TagManager.instance.uri_for(object.account)
   end
 
+  def published
+    object.created_at.iso8601
+  end
+
   def to
     ActivityPub::TagManager.instance.to(object)
   end
@@ -24,4 +28,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
   def cc
     ActivityPub::TagManager.instance.cc(object)
   end
+
+  def announce?
+    object.reblog?
+  end
 end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index f5e626d73..a11178f5b 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -4,11 +4,41 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   include RoutingHelper
 
   attributes :id, :type, :following, :followers,
-             :inbox, :outbox, :preferred_username,
-             :name, :summary, :icon, :image
+             :inbox, :outbox,
+             :preferred_username, :name, :summary,
+             :url, :manually_approves_followers
 
   has_one :public_key, serializer: ActivityPub::PublicKeySerializer
 
+  class ImageSerializer < ActiveModel::Serializer
+    include RoutingHelper
+
+    attributes :type, :url
+
+    def type
+      'Image'
+    end
+
+    def url
+      full_asset_url(object.url(:original))
+    end
+  end
+
+  class EndpointsSerializer < ActiveModel::Serializer
+    include RoutingHelper
+
+    attributes :shared_inbox
+
+    def shared_inbox
+      inbox_url
+    end
+  end
+
+  has_one :endpoints, serializer: EndpointsSerializer
+
+  has_one :icon,  serializer: ImageSerializer, if: :avatar_exists?
+  has_one :image, serializer: ImageSerializer, if: :header_exists?
+
   def id
     account_url(object)
   end
@@ -26,13 +56,17 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def inbox
-    nil
+    account_inbox_url(object)
   end
 
   def outbox
     account_outbox_url(object)
   end
 
+  def endpoints
+    object
+  end
+
   def preferred_username
     object.username
   end
@@ -46,14 +80,30 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def icon
-    full_asset_url(object.avatar.url(:original))
+    object.avatar
   end
 
   def image
-    full_asset_url(object.header.url(:original))
+    object.header
   end
 
   def public_key
     object
   end
+
+  def url
+    short_account_url(object)
+  end
+
+  def avatar_exists?
+    object.avatar.exists?
+  end
+
+  def header_exists?
+    object.header.exists?
+  end
+
+  def manually_approves_followers
+    object.locked
+  end
 end
diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb
index a001b213b..b3bd9f868 100644
--- a/app/serializers/activitypub/block_serializer.rb
+++ b/app/serializers/activitypub/block_serializer.rb
@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::BlockSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
+  end
+
   def type
     'Block'
   end
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
index d01dead28..9832133fc 100644
--- a/app/serializers/activitypub/collection_serializer.rb
+++ b/app/serializers/activitypub/collection_serializer.rb
@@ -3,23 +3,38 @@
 class ActivityPub::CollectionSerializer < ActiveModel::Serializer
   def self.serializer_for(model, options)
     return ActivityPub::ActivitySerializer if model.class.name == 'Status'
+    return ActivityPub::CollectionSerializer if model.class.name == 'ActivityPub::CollectionPresenter'
     super
   end
 
   attributes :id, :type, :total_items
+  attribute :next, if: -> { object.next.present? }
+  attribute :prev, if: -> { object.prev.present? }
+  attribute :part_of, if: -> { object.part_of.present? }
 
-  has_many :items, key: :ordered_items
+  has_one :first, if: -> { object.first.present? }
+  has_many :items, key: :items, if: -> { (object.items.present? || page?) && !ordered? }
+  has_many :items, key: :ordered_items, if: -> { (object.items.present? || page?) && ordered? }
 
   def type
-    case object.type
-    when :ordered
-      'OrderedCollection'
+    if page?
+      ordered? ? 'OrderedCollectionPage' : 'CollectionPage'
     else
-      'Collection'
+      ordered? ? 'OrderedCollection' : 'Collection'
     end
   end
 
   def total_items
     object.size
   end
+
+  private
+
+  def ordered?
+    object.type == :ordered
+  end
+
+  def page?
+    object.part_of.present?
+  end
 end
diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb
index 77098b1b0..2bb65135f 100644
--- a/app/serializers/activitypub/delete_serializer.rb
+++ b/app/serializers/activitypub/delete_serializer.rb
@@ -1,8 +1,29 @@
 # frozen_string_literal: true
 
 class ActivityPub::DeleteSerializer < ActiveModel::Serializer
-  attributes :type, :actor
-  attribute :virtual_object, key: :object
+  class TombstoneSerializer < ActiveModel::Serializer
+    attributes :id, :type, :atom_uri
+
+    def id
+      ActivityPub::TagManager.instance.uri_for(object)
+    end
+
+    def type
+      'Tombstone'
+    end
+
+    def atom_uri
+      OStatus::TagManager.instance.uri_for(object)
+    end
+  end
+
+  attributes :id, :type, :actor
+
+  has_one :object, serializer: TombstoneSerializer
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join
+  end
 
   def type
     'Delete'
@@ -11,8 +32,4 @@ class ActivityPub::DeleteSerializer < ActiveModel::Serializer
   def actor
     ActivityPub::TagManager.instance.uri_for(object.account)
   end
-
-  def virtual_object
-    ActivityPub::TagManager.instance.uri_for(object)
-  end
 end
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index 1953a2d7b..86c9992fe 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::FollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
+  end
+
   def type
     'Follow'
   end
diff --git a/app/serializers/activitypub/like_serializer.rb b/app/serializers/activitypub/like_serializer.rb
index 4226913f5..c1a7ff6f6 100644
--- a/app/serializers/activitypub/like_serializer.rb
+++ b/app/serializers/activitypub/like_serializer.rb
@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::LikeSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join
+  end
+
   def type
     'Like'
   end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 4c13f8e59..f94c3b9dc 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,7 +3,9 @@
 class ActivityPub::NoteSerializer < ActiveModel::Serializer
   attributes :id, :type, :summary, :content,
              :in_reply_to, :published, :url,
-             :attributed_to, :to, :cc, :sensitive
+             :attributed_to, :to, :cc, :sensitive,
+             :atom_uri, :in_reply_to_atom_uri,
+             :conversation
 
   has_many :media_attachments, key: :attachment
   has_many :virtual_tags, key: :tag
@@ -25,7 +27,13 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
   end
 
   def in_reply_to
-    ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply?
+    return unless object.reply? && !object.thread.nil?
+
+    if object.thread.uri.nil? || object.thread.uri.start_with?('http')
+      ActivityPub::TagManager.instance.uri_for(object.thread)
+    else
+      object.thread.url
+    end
   end
 
   def published
@@ -49,7 +57,33 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
   end
 
   def virtual_tags
-    object.mentions + object.tags
+    object.mentions + object.tags + object.emojis
+  end
+
+  def atom_uri
+    return unless object.local?
+
+    OStatus::TagManager.instance.uri_for(object)
+  end
+
+  def in_reply_to_atom_uri
+    return unless object.reply? && !object.thread.nil?
+
+    OStatus::TagManager.instance.uri_for(object.thread)
+  end
+
+  def conversation
+    return if object.conversation.nil?
+
+    if object.conversation.uri?
+      object.conversation.uri
+    else
+      OStatus::TagManager.instance.unique_tag(object.conversation.created_at, object.conversation.id, 'Conversation')
+    end
+  end
+
+  def local?
+    object.account.local?
   end
 
   class MediaAttachmentSerializer < ActiveModel::Serializer
@@ -103,4 +137,22 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
       "##{object.name}"
     end
   end
+
+  class CustomEmojiSerializer < ActiveModel::Serializer
+    include RoutingHelper
+
+    attributes :type, :href, :name
+
+    def type
+      'Emoji'
+    end
+
+    def href
+      full_asset_url(object.image.url)
+    end
+
+    def name
+      ":#{object.shortcode}:"
+    end
+  end
 end
diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb
index 28584d627..7814f4f57 100644
--- a/app/serializers/activitypub/reject_follow_serializer.rb
+++ b/app/serializers/activitypub/reject_follow_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join
+  end
+
   def type
     'Reject'
   end
diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb
new file mode 100644
index 000000000..839847e22
--- /dev/null
+++ b/app/serializers/activitypub/undo_announce_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class ActivityPub::UndoAnnounceSerializer < ActiveModel::Serializer
+  attributes :id, :type, :actor
+
+  has_one :object, serializer: ActivityPub::ActivitySerializer
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join
+  end
+
+  def type
+    'Undo'
+  end
+
+  def actor
+    ActivityPub::TagManager.instance.uri_for(object.account)
+  end
+end
diff --git a/app/serializers/activitypub/undo_block_serializer.rb b/app/serializers/activitypub/undo_block_serializer.rb
index f71faa729..2f43d8402 100644
--- a/app/serializers/activitypub/undo_block_serializer.rb
+++ b/app/serializers/activitypub/undo_block_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::BlockSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end
diff --git a/app/serializers/activitypub/undo_follow_serializer.rb b/app/serializers/activitypub/undo_follow_serializer.rb
index fe91f5f1c..e5b7f143d 100644
--- a/app/serializers/activitypub/undo_follow_serializer.rb
+++ b/app/serializers/activitypub/undo_follow_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end
diff --git a/app/serializers/activitypub/undo_like_serializer.rb b/app/serializers/activitypub/undo_like_serializer.rb
index db9cd1d0d..25f4ccaae 100644
--- a/app/serializers/activitypub/undo_like_serializer.rb
+++ b/app/serializers/activitypub/undo_like_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::LikeSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end
diff --git a/app/serializers/activitypub/update_serializer.rb b/app/serializers/activitypub/update_serializer.rb
index 322305da8..ebc667d96 100644
--- a/app/serializers/activitypub/update_serializer.rb
+++ b/app/serializers/activitypub/update_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UpdateSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::ActorSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join
+  end
+
   def type
     'Update'
   end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 0191948b1..e2f15a601 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -4,22 +4,27 @@ class InitialStateSerializer < ActiveModel::Serializer
   attributes :meta, :compose, :accounts,
              :media_attachments, :settings, :push_subscription
 
+  has_many :custom_emojis, serializer: REST::CustomEmojiSerializer
+
+  def custom_emojis
+    CustomEmoji.local
+  end
+
   def meta
     store = {
       streaming_api_base_url: Rails.configuration.x.streaming_api_base_url,
       access_token: object.token,
       locale: I18n.locale,
       domain: Rails.configuration.x.local_domain,
-      admin: object.admin&.id,
+      admin: object.admin&.id&.to_s,
     }
 
     if object.current_account
-      store[:me]             = object.current_account.id
+      store[:me]             = object.current_account.id.to_s
       store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal
       store[:boost_modal]    = object.current_account.user.setting_boost_modal
       store[:delete_modal]   = object.current_account.user.setting_delete_modal
       store[:auto_play_gif]  = object.current_account.user.setting_auto_play_gif
-      store[:system_font_ui] = object.current_account.user.setting_system_font_ui
     end
 
     store
@@ -29,22 +34,24 @@ class InitialStateSerializer < ActiveModel::Serializer
     store = {}
 
     if object.current_account
-      store[:me]                = object.current_account.id
+      store[:me]                = object.current_account.id.to_s
       store[:default_privacy]   = object.current_account.user.setting_default_privacy
       store[:default_sensitive] = object.current_account.user.setting_default_sensitive
     end
 
+    store[:text] = object.text if object.text
+
     store
   end
 
   def accounts
     store = {}
-    store[object.current_account.id] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account
-    store[object.admin.id]           = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin
+    store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account
+    store[object.admin.id.to_s]           = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin
     store
   end
 
   def media_attachments
-    { accept_content_types: MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES }
+    { accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES }
   end
 end
diff --git a/app/serializers/oembed_serializer.rb b/app/serializers/oembed_serializer.rb
index 78376d253..0c8350e2d 100644
--- a/app/serializers/oembed_serializer.rb
+++ b/app/serializers/oembed_serializer.rb
@@ -21,7 +21,7 @@ class OEmbedSerializer < ActiveModel::Serializer
   end
 
   def author_url
-    account_url(object.account)
+    short_account_url(object.account)
   end
 
   def provider_name
@@ -37,13 +37,15 @@ class OEmbedSerializer < ActiveModel::Serializer
   end
 
   def html
-    tag :iframe,
-        src: embed_account_stream_entry_url(object.account, object),
-        style: 'width: 100%; overflow: hidden',
-        frameborder: '0',
-        scrolling: 'no',
-        width: width,
-        height: height
+    attributes = {
+      src: embed_short_account_status_url(object.account, object),
+      class: 'mastodon-embed',
+      style: 'max-width: 100%; border: 0',
+      width: width,
+      height: height,
+    }
+
+    content_tag(:iframe, nil, attributes) + content_tag(:script, nil, src: full_asset_url('embed.js', skip_pipeline: true), async: true)
   end
 
   def width
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 012a4fd18..65fdb0308 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -7,6 +7,10 @@ class REST::AccountSerializer < ActiveModel::Serializer
              :note, :url, :avatar, :avatar_static, :header, :header_static,
              :followers_count, :following_count, :statuses_count
 
+  def id
+    object.id.to_s
+  end
+
   def note
     Formatter.instance.simplified_format(object)
   end
diff --git a/app/serializers/rest/application_serializer.rb b/app/serializers/rest/application_serializer.rb
index 868a62f1e..a8945f66e 100644
--- a/app/serializers/rest/application_serializer.rb
+++ b/app/serializers/rest/application_serializer.rb
@@ -4,6 +4,10 @@ class REST::ApplicationSerializer < ActiveModel::Serializer
   attributes :id, :name, :website, :redirect_uri,
              :client_id, :client_secret
 
+  def id
+    object.id.to_s
+  end
+
   def client_id
     object.uid
   end
diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb
new file mode 100644
index 000000000..b744dd4ec
--- /dev/null
+++ b/app/serializers/rest/custom_emoji_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class REST::CustomEmojiSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :shortcode, :url
+
+  def url
+    full_asset_url(object.image.url)
+  end
+end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index 8e32f9cb3..2898011fd 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -1,8 +1,10 @@
 # frozen_string_literal: true
 
 class REST::InstanceSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
   attributes :uri, :title, :description, :email,
-             :version, :urls
+             :version, :urls, :stats, :thumbnail
 
   def uri
     Rails.configuration.x.local_domain
@@ -24,7 +26,25 @@ class REST::InstanceSerializer < ActiveModel::Serializer
     Mastodon::Version.to_s
   end
 
+  def thumbnail
+    full_asset_url(instance_presenter.thumbnail.file.url) if instance_presenter.thumbnail
+  end
+
+  def stats
+    {
+      user_count: instance_presenter.user_count,
+      status_count: instance_presenter.status_count,
+      domain_count: instance_presenter.domain_count,
+    }
+  end
+
   def urls
     { streaming_api: Rails.configuration.x.streaming_api_base_url }
   end
+
+  private
+
+  def instance_presenter
+    @instance_presenter ||= InstancePresenter.new
+  end
 end
diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb
index 9055b8db4..f6e7c79d1 100644
--- a/app/serializers/rest/media_attachment_serializer.rb
+++ b/app/serializers/rest/media_attachment_serializer.rb
@@ -6,12 +6,24 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
   attributes :id, :type, :url, :preview_url,
              :remote_url, :text_url, :meta
 
+  def id
+    object.id.to_s
+  end
+
   def url
-    full_asset_url(object.file.url(:original))
+    if object.needs_redownload?
+      media_proxy_url(object.id, :original)
+    else
+      full_asset_url(object.file.url(:original))
+    end
   end
 
   def preview_url
-    full_asset_url(object.file.url(:small))
+    if object.needs_redownload?
+      media_proxy_url(object.id, :small)
+    else
+      full_asset_url(object.file.url(:small))
+    end
   end
 
   def text_url
diff --git a/app/serializers/rest/mute_serializer.rb b/app/serializers/rest/mute_serializer.rb
new file mode 100644
index 000000000..043a2f059
--- /dev/null
+++ b/app/serializers/rest/mute_serializer.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class REST::MuteSerializer < ActiveModel::Serializer
+  include RoutingHelper
+  
+  attributes :id, :account, :target_account, :created_at, :hide_notifications
+
+  def account
+    REST::AccountSerializer.new(object.account)
+  end
+
+  def target_account
+    REST::AccountSerializer.new(object.target_account)
+  end
+end
\ No newline at end of file
diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb
index f95d099a3..541a6b8b5 100644
--- a/app/serializers/rest/notification_serializer.rb
+++ b/app/serializers/rest/notification_serializer.rb
@@ -6,6 +6,10 @@ class REST::NotificationSerializer < ActiveModel::Serializer
   belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
   belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
 
+  def id
+    object.id.to_s
+  end
+
   def status_type?
     [:favourite, :reblog, :mention].include?(object.type)
   end
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index 1d431aa1b..998727e37 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -4,6 +4,10 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
   attributes :id, :following, :followed_by, :blocking,
              :muting, :requested, :domain_blocking
 
+  def id
+    object.id.to_s
+  end
+
   def following
     instance_options[:relationships].following[object.id] || false
   end
diff --git a/app/serializers/rest/report_serializer.rb b/app/serializers/rest/report_serializer.rb
index 0c6bd6556..ecb88d653 100644
--- a/app/serializers/rest/report_serializer.rb
+++ b/app/serializers/rest/report_serializer.rb
@@ -2,4 +2,8 @@
 
 class REST::ReportSerializer < ActiveModel::Serializer
   attributes :id, :action_taken
+
+  def id
+    object.id.to_s
+  end
 end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 246b12a90..e6270f902 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -8,6 +8,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
   attribute :muted, if: :current_user?
+  attribute :pinned, if: :pinnable?
 
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application
@@ -16,13 +17,26 @@ class REST::StatusSerializer < ActiveModel::Serializer
   has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
   has_many :mentions
   has_many :tags
+  has_many :emojis, serializer: REST::CustomEmojiSerializer
+
+  def id
+    object.id.to_s
+  end
+
+  def in_reply_to_id
+    object.in_reply_to_id&.to_s
+  end
+
+  def in_reply_to_account_id
+    object.in_reply_to_account_id&.to_s
+  end
 
   def current_user?
     !current_user.nil?
   end
 
   def uri
-    TagManager.instance.uri_for(object)
+    OStatus::TagManager.instance.uri_for(object)
   end
 
   def content
@@ -57,6 +71,21 @@ class REST::StatusSerializer < ActiveModel::Serializer
     end
   end
 
+  def pinned
+    if instance_options && instance_options[:relationships]
+      instance_options[:relationships].pins_map[object.id] || false
+    else
+      current_user.account.pinned?(object)
+    end
+  end
+
+  def pinnable?
+    current_user? &&
+      current_user.account_id == object.account_id &&
+      !object.reblog? &&
+      %w(public unlisted).include?(object.visibility)
+  end
+
   class ApplicationSerializer < ActiveModel::Serializer
     attributes :name, :website
   end
@@ -65,7 +94,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
     attributes :id, :username, :url, :acct
 
     def id
-      object.account_id
+      object.account_id.to_s
     end
 
     def username
diff --git a/app/serializers/web/notification_serializer.rb b/app/serializers/web/notification_serializer.rb
new file mode 100644
index 000000000..e5524fe7a
--- /dev/null
+++ b/app/serializers/web/notification_serializer.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+class Web::NotificationSerializer < ActiveModel::Serializer
+  include RoutingHelper
+  include StreamEntriesHelper
+
+  class DataSerializer < ActiveModel::Serializer
+    include RoutingHelper
+    include StreamEntriesHelper
+    include ActionView::Helpers::SanitizeHelper
+
+    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
+  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
+  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
+    object.id
+  end
+
+  def timestamp
+    object.created_at
+  end
+
+  def icon
+    object.from_account.avatar_static_url
+  end
+
+  def data
+    object
+  end
+
+  private
+
+  def name
+    display_name(object.from_account)
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
new file mode 100644
index 000000000..3eeca585e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteAccountService < BaseService
+  include JsonLdHelper
+
+  # Should be called when uri has already been checked for locality
+  # Does a WebFinger roundtrip on each call
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context? && expected_type?
+
+    @uri      = @json['id']
+    @username = @json['preferredUsername']
+    @domain   = Addressable::URI.parse(uri).normalized_host
+
+    return unless verified_webfinger?
+
+    ActivityPub::ProcessAccountService.new.call(@username, @domain, @json)
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def verified_webfinger?
+    webfinger                            = Goldfinger.finger("acct:#{@username}@#{@domain}")
+    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+
+    return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+
+    webfinger                            = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}")
+    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+    self_reference                       = webfinger.link('self')
+
+    return false if self_reference&.href != @uri
+
+    @username = confirmed_username
+    @domain   = confirmed_domain
+
+    true
+  rescue Goldfinger::Error
+    false
+  end
+
+  def split_acct(acct)
+    acct.gsub(/\Aacct:/, '').split('@')
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?
+    @json['type'] == 'Person'
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
new file mode 100644
index 000000000..ebd64071e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteKeyService < BaseService
+  include JsonLdHelper
+
+  # Returns account that owns the key
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context?(@json) && expected_type?
+    return find_account(uri, @json) if person?
+
+    @owner = fetch_resource(owner_uri)
+
+    return unless supported_context?(@owner) && confirmed_owner?
+
+    find_account(owner_uri, @owner)
+  end
+
+  private
+
+  def find_account(uri, prefetched_json)
+    account   = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+    account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json)
+    account
+  end
+
+  def expected_type?
+    person? || public_key?
+  end
+
+  def person?
+    @json['type'] == 'Person'
+  end
+
+  def public_key?
+    @json['publicKeyPem'].present? && @json['owner'].present?
+  end
+
+  def owner_uri
+    @owner_uri ||= value_or_id(@json['owner'])
+  end
+
+  def confirmed_owner?
+    @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id']
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
new file mode 100644
index 000000000..a95931afe
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteStatusService < BaseService
+  include JsonLdHelper
+
+  # Should be called when uri has already been checked for locality
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context?
+
+    activity = activity_json
+    actor_id = value_or_id(activity['actor'])
+
+    return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id)
+
+    actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
+    actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil?
+
+    return if actor.suspended?
+
+    ActivityPub::Activity.factory(activity, actor).perform
+  end
+
+  private
+
+  def activity_json
+    if %w(Note Article).include? @json['type']
+      {
+        'type'   => 'Create',
+        'actor'  => first_of_value(@json['attributedTo']),
+        'object' => @json,
+      }
+    else
+      @json
+    end
+  end
+
+  def trustworthy_attribution?(uri, attributed_to)
+    Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?(json)
+    %w(Create Announce).include? json['type']
+  end
+end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
new file mode 100644
index 000000000..811209537
--- /dev/null
+++ b/app/services/activitypub/process_account_service.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessAccountService < BaseService
+  include JsonLdHelper
+
+  # Should be called with confirmed valid JSON
+  # and WebFinger-resolved username and domain
+  def call(username, domain, json)
+    return if json['inbox'].blank?
+
+    @json        = json
+    @uri         = @json['id']
+    @username    = username
+    @domain      = domain
+    @collections = {}
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @account        = Account.find_by(uri: @uri)
+        @old_public_key = @account&.public_key
+        @old_protocol   = @account&.protocol
+
+        create_account if @account.nil?
+        update_account
+      end
+    end
+
+    after_protocol_change! if protocol_changed?
+    after_key_change! if key_changed?
+
+    @account
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def create_account
+    @account = Account.new
+    @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
+  end
+
+  def update_account
+    @account.last_webfingered_at = Time.now.utc
+    @account.protocol            = :activitypub
+
+    set_immediate_attributes!
+    set_fetchable_attributes!
+
+    @account.save_with_optional_media!
+  end
+
+  def set_immediate_attributes!
+    @account.inbox_url        = @json['inbox'] || ''
+    @account.outbox_url       = @json['outbox'] || ''
+    @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
+    @account.followers_url    = @json['followers'] || ''
+    @account.url              = url || @uri
+    @account.display_name     = @json['name'] || ''
+    @account.note             = @json['summary'] || ''
+    @account.locked           = @json['manuallyApprovesFollowers'] || false
+  end
+
+  def set_fetchable_attributes!
+    @account.avatar_remote_url = image_url('icon')  unless skip_download?
+    @account.header_remote_url = image_url('image') unless skip_download?
+    @account.public_key        = public_key || ''
+    @account.statuses_count    = outbox_total_items    if outbox_total_items.present?
+    @account.following_count   = following_total_items if following_total_items.present?
+    @account.followers_count   = followers_total_items if followers_total_items.present?
+  end
+
+  def after_protocol_change!
+    ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
+  end
+
+  def after_key_change!
+    RefollowWorker.perform_async(@account.id)
+  end
+
+  def image_url(key)
+    value = first_of_value(@json[key])
+
+    return if value.nil?
+    return value['url'] if value.is_a?(Hash)
+
+    image = fetch_resource(value)
+    image['url'] if image
+  end
+
+  def public_key
+    value = first_of_value(@json['publicKey'])
+
+    return if value.nil?
+    return value['publicKeyPem'] if value.is_a?(Hash)
+
+    key = fetch_resource(value)
+    key['publicKeyPem'] if key
+  end
+
+  def url
+    return if @json['url'].blank?
+
+    value = first_of_value(@json['url'])
+
+    return value if value.is_a?(String)
+
+    value['href']
+  end
+
+  def outbox_total_items
+    collection_total_items('outbox')
+  end
+
+  def following_total_items
+    collection_total_items('following')
+  end
+
+  def followers_total_items
+    collection_total_items('followers')
+  end
+
+  def collection_total_items(type)
+    return if @json[type].blank?
+    return @collections[type] if @collections.key?(type)
+
+    collection = fetch_resource(@json[type])
+
+    @collections[type] = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil
+  rescue HTTP::Error, OpenSSL::SSL::SSLError
+    @collections[type] = nil
+  end
+
+  def skip_download?
+    @account.suspended? || domain_block&.reject_media?
+  end
+
+  def auto_suspend?
+    domain_block&.suspend?
+  end
+
+  def auto_silence?
+    domain_block&.silence?
+  end
+
+  def domain_block
+    return @domain_block if defined?(@domain_block)
+    @domain_block = DomainBlock.find_by(domain: @domain)
+  end
+
+  def key_changed?
+    !@old_public_key.nil? && @old_public_key != @account.public_key
+  end
+
+  def protocol_changed?
+    !@old_protocol.nil? && @old_protocol != @account.protocol
+  end
+
+  def lock_options
+    { redis: Redis.current, key: "process_account:#{@uri}" }
+  end
+end
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
new file mode 100644
index 000000000..59cb65c65
--- /dev/null
+++ b/app/services/activitypub/process_collection_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessCollectionService < BaseService
+  include JsonLdHelper
+
+  def call(body, account)
+    @account = account
+    @json    = Oj.load(body, mode: :strict)
+
+    return unless supported_context?
+    return if different_actor? && verify_account!.nil?
+    return if @account.suspended? || @account.local?
+
+    case @json['type']
+    when 'Collection', 'CollectionPage'
+      process_items @json['items']
+    when 'OrderedCollection', 'OrderedCollectionPage'
+      process_items @json['orderedItems']
+    else
+      process_items [@json]
+    end
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def different_actor?
+    @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present?
+  end
+
+  def process_items(items)
+    items.reverse_each.map { |item| process_item(item) }.compact
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def process_item(item)
+    activity = ActivityPub::Activity.factory(item, @account)
+    activity&.perform
+  end
+
+  def verify_account!
+    @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+  end
+end
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
index 41815a393..b1bff8962 100644
--- a/app/services/authorize_follow_service.rb
+++ b/app/services/authorize_follow_service.rb
@@ -1,14 +1,36 @@
 # frozen_string_literal: true
 
 class AuthorizeFollowService < BaseService
-  def call(source_account, target_account)
-    follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
-    follow_request.authorize!
-    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+  def call(source_account, target_account, options = {})
+    if options[:skip_follow_request]
+      follow_request = FollowRequest.new(account: source_account, target_account: target_account)
+    else
+      follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+      follow_request.authorize!
+    end
+
+    create_notification(follow_request) unless source_account.local?
+    follow_request
   end
 
   private
 
+  def create_notification(follow_request)
+    if follow_request.account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
+    elsif follow_request.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
+    end
+  end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::AcceptFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.target_account))
+  end
+
   def build_xml(follow_request)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request))
   end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index ab810c628..2fd623922 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -15,19 +15,26 @@ class BatchedRemoveStatusService < BaseService
     @mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h
     @tags     = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h
 
-    @stream_entry_batches = []
-    @salmon_batches       = []
-    @json_payloads        = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
+    @stream_entry_batches  = []
+    @salmon_batches        = []
+    @activity_json_batches = []
+    @json_payloads         = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id.to_s)] }.to_h
+    @activity_json         = {}
+    @activity_xml          = {}
 
     # Ensure that rendered XML reflects destroyed state
-    Status.where(id: statuses.map(&:id)).in_batches.destroy_all
+    statuses.each(&:destroy)
 
     # Batch by source account
     statuses.group_by(&:account_id).each do |_, account_statuses|
       account = account_statuses.first.account
 
       unpush_from_home_timelines(account_statuses)
-      batch_stream_entries(account_statuses) if account.local?
+
+      if account.local?
+        batch_stream_entries(account, account_statuses)
+        batch_activity_json(account, account_statuses)
+      end
     end
 
     # Cannot be batched
@@ -36,17 +43,32 @@ class BatchedRemoveStatusService < BaseService
       batch_salmon_slaps(status) if status.local?
     end
 
-    Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
+    Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
     NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
+    ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch }
   end
 
   private
 
-  def batch_stream_entries(statuses)
-    stream_entry_ids = statuses.map { |s| s.stream_entry.id }
+  def batch_stream_entries(account, statuses)
+    statuses.each do |status|
+      @stream_entry_batches << [build_xml(status.stream_entry), account.id]
+    end
+  end
+
+  def batch_activity_json(account, statuses)
+    account.followers.inboxes.each do |inbox_url|
+      statuses.each do |status|
+        @activity_json_batches << [build_json(status), account.id, inbox_url]
+      end
+    end
+
+    statuses.each do |status|
+      other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id)
 
-    stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids|
-      @stream_entry_batches << [batch_of_stream_entry_ids]
+      other_recipients.each do |target_account|
+        @activity_json_batches << [build_json(status), account.id, target_account.inbox_url]
+      end
     end
   end
 
@@ -62,6 +84,8 @@ class BatchedRemoveStatusService < BaseService
   end
 
   def unpush_from_public_timelines(status)
+    return unless status.public_visibility?
+
     payload = @json_payloads[status.id]
 
     redis.pipelined do
@@ -78,11 +102,10 @@ class BatchedRemoveStatusService < BaseService
   def batch_salmon_slaps(status)
     return if @mentions[status.id].empty?
 
-    payload    = stream_entry_to_xml(status.stream_entry.reload)
-    recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id)
+    recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id)
 
     recipients.each do |recipient_id|
-      @salmon_batches << [payload, status.account_id, recipient_id]
+      @salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id]
     end
   end
 
@@ -111,4 +134,24 @@ class BatchedRemoveStatusService < BaseService
   def redis
     Redis.current
   end
+
+  def build_json(status)
+    return @activity_json[status.id] if @activity_json.key?(status.id)
+
+    @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
+      status,
+      serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json)
+  end
+
+  def build_xml(stream_entry)
+    return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
+
+    @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
+  end
+
+  def sign_json(status, json)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
+  end
 end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index a6b3c4cdb..eefdc0dbf 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -26,11 +26,12 @@ class BlockDomainService < BaseService
   def clear_media!
     clear_account_images
     clear_account_attachments
+    clear_emojos
   end
 
   def suspend_accounts!
     blocked_domain_accounts.where(suspended: false).find_each do |account|
-      account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
+      UnsubscribeService.new.call(account) if account.subscribed?
       SuspendAccountService.new.call(account)
     end
   end
@@ -51,6 +52,10 @@ class BlockDomainService < BaseService
     end
   end
 
+  def clear_emojos
+    emojis_from_blocked_domains.destroy_all
+  end
+
   def blocked_domain
     domain_block.domain
   end
@@ -62,4 +67,8 @@ class BlockDomainService < BaseService
   def media_from_blocked_domain
     MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil)
   end
+
+  def emojis_from_blocked_domains
+    CustomEmoji.where(domain: blocked_domain)
+  end
 end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 5d7bf6a3b..b39c3eef2 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -12,11 +12,28 @@ class BlockService < BaseService
     block = account.block!(target_account)
 
     BlockWorker.perform_async(account.id, target_account.id)
-    NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local?
+    create_notification(block) unless target_account.local?
+    block
   end
 
   private
 
+  def create_notification(block)
+    if block.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id)
+    elsif block.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url)
+    end
+  end
+
+  def build_json(block)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      block,
+      serializer: ActivityPub::BlockSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(block.account))
+  end
+
   def build_xml(block)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block))
   end
diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb
new file mode 100644
index 000000000..c01e25824
--- /dev/null
+++ b/app/services/bootstrap_timeline_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class BootstrapTimelineService < BaseService
+  def call(source_account)
+    bootstrap_timeline_accounts.each do |target_account|
+      FollowService.new.call(source_account, target_account)
+    end
+  end
+
+  private
+
+  def bootstrap_timeline_accounts
+    return @bootstrap_timeline_accounts if defined?(@bootstrap_timeline_accounts)
+
+    @bootstrap_timeline_accounts = bootstrap_timeline_accounts_usernames.empty? ? admin_accounts : local_unlocked_accounts(bootstrap_timeline_accounts_usernames)
+  end
+
+  def bootstrap_timeline_accounts_usernames
+    @bootstrap_timeline_accounts_usernames ||= (Setting.bootstrap_timeline_accounts || '').split(',').map { |str| str.strip.gsub(/\A@/, '') }.reject(&:blank?)
+  end
+
+  def admin_accounts
+    User.admins
+        .includes(:account)
+        .where(accounts: { locked: false })
+        .map(&:account)
+  end
+
+  def local_unlocked_accounts(usernames)
+    Account.local
+           .where(username: usernames)
+           .where(locked: false)
+  end
+end
diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb
index 867d6dc25..c2366188a 100644
--- a/app/services/concerns/author_extractor.rb
+++ b/app/services/concerns/author_extractor.rb
@@ -5,12 +5,12 @@ module AuthorExtractor
     return nil if xml.nil?
 
     # Try <email> for acct
-    acct = xml.at_xpath('./xmlns:author/xmlns:email', xmlns: TagManager::XMLNS)&.content
+    acct = xml.at_xpath('./xmlns:author/xmlns:email', xmlns: OStatus::TagManager::XMLNS)&.content
 
     # Try <name> + <uri>
     if acct.blank?
-      username = xml.at_xpath('./xmlns:author/xmlns:name', xmlns: TagManager::XMLNS)&.content
-      uri      = xml.at_xpath('./xmlns:author/xmlns:uri', xmlns: TagManager::XMLNS)&.content
+      username = xml.at_xpath('./xmlns:author/xmlns:name', xmlns: OStatus::TagManager::XMLNS)&.content
+      uri      = xml.at_xpath('./xmlns:author/xmlns:uri', xmlns: OStatus::TagManager::XMLNS)&.content
 
       return nil if username.blank? || uri.blank?
 
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 291f9e56e..44df3ed13 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -15,18 +15,32 @@ class FavouriteService < BaseService
     return favourite unless favourite.nil?
 
     favourite = Favourite.create!(account: account, status: status)
-
-    if status.local?
-      NotifyService.new.call(favourite.status.account, favourite)
-    else
-      NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id)
-    end
-
+    create_notification(favourite)
     favourite
   end
 
   private
 
+  def create_notification(favourite)
+    status = favourite.status
+
+    if status.account.local?
+      NotifyService.new.call(status.account, favourite)
+    elsif status.account.ostatus?
+      NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
+    elsif status.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
+    end
+  end
+
+  def build_json(favourite)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      favourite,
+      serializer: ActivityPub::LikeSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(favourite.account))
+  end
+
   def build_xml(favourite)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite))
   end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 3ac441e3e..9c5777b5d 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -1,21 +1,17 @@
 # frozen_string_literal: true
 
 class FetchAtomService < BaseService
+  include JsonLdHelper
+
   def call(url)
     return if url.blank?
 
-    response = Request.new(:head, url).perform
-
-    Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
+    result = process(url)
 
-    response = Request.new(:get, url).perform if response.code == 405
+    # retry without ActivityPub
+    result ||= process(url) if @unsupported_activity
 
-    Rails.logger.debug "Remote status GET request returned code #{response.code}"
-
-    return nil if response.code != 200
-    return [url, fetch(url)] if response.mime_type == 'application/atom+xml'
-    return process_headers(url, response) if response['Link'].present?
-    process_html(fetch(url))
+    result
   rescue OpenSSL::SSL::SSLError => e
     Rails.logger.debug "SSL error: #{e}"
     nil
@@ -26,27 +22,67 @@ class FetchAtomService < BaseService
 
   private
 
-  def process_html(body)
-    Rails.logger.debug 'Processing HTML'
+  def process(url, terminal = false)
+    @url = url
+    perform_request
+    process_response(terminal)
+  end
+
+  def perform_request
+    accept = 'text/html'
+    accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity
+
+    @response = Request.new(:get, @url)
+                       .add_headers('Accept' => accept)
+                       .perform
+  end
 
-    page = Nokogiri::HTML(body)
-    alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
+  def process_response(terminal = false)
+    return nil if @response.code != 200
 
-    return nil if alternate_link.nil?
-    [alternate_link['href'], fetch(alternate_link['href'])]
+    if @response.mime_type == 'application/atom+xml'
+      [@url, @response.to_s, :ostatus]
+    elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type)
+      if supported_activity?(@response.to_s)
+        [@url, @response.to_s, :activitypub]
+      else
+        @unsupported_activity = true
+        nil
+      end
+    elsif @response['Link'] && !terminal
+      process_headers
+    elsif @response.mime_type == 'text/html' && !terminal
+      process_html
+    end
   end
 
-  def process_headers(url, response)
-    Rails.logger.debug 'Processing link header'
+  def process_html
+    page = Nokogiri::HTML(@response.to_s)
+
+    json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
+    atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
+
+    result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity
+    result ||= process(atom_link['href'], terminal: true) unless atom_link.nil?
+
+    result
+  end
+
+  def process_headers
+    link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
+
+    json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
+    atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
 
-    link_header    = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
-    alternate_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
+    result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity
+    result ||= process(atom_link.href, terminal: true) unless atom_link.nil?
 
-    return process_html(fetch(url)) if alternate_link.nil?
-    [alternate_link.href, fetch(alternate_link.href)]
+    result
   end
 
-  def fetch(url)
-    Request.new(:get, url).perform.to_s
+  def supported_activity?(body)
+    json = body_to_json(body)
+    return false unless supported_context?(json)
+    json['type'] == 'Person' ? json['inbox'].present? : true
   end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 20c85e0ea..4acbfae7a 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -1,32 +1,56 @@
 # frozen_string_literal: true
 
 class FetchLinkCardService < BaseService
-  URL_PATTERN = %r{https?://\S+}
+  URL_PATTERN = %r{
+    (                                                                                                 #   $1 URL
+      (https?:\/\/)?                                                                                  #   $2 Protocol (optional)
+      (#{Twitter::Regex[:valid_domain]})                                                              #   $3 Domain(s)
+      (?::(#{Twitter::Regex[:valid_port_number]}))?                                                   #   $4 Port number (optional)
+      (/#{Twitter::Regex[:valid_url_path]}*)?                                                         #   $5 URL Path and anchor
+      (\?#{Twitter::Regex[:valid_url_query_chars]}*#{Twitter::Regex[:valid_url_query_ending_chars]})? #   $6 Query String
+    )
+  }iox
 
   def call(status)
-    # Get first http/https URL that isn't local
-    url = parse_urls(status)
+    @status = status
+    @url    = parse_urls
 
-    return if url.nil?
+    return if @url.nil? || @status.preview_cards.any?
 
-    url  = url.to_s
-    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
-    res  = Request.new(:head, url).perform
+    @url = @url.to_s
 
-    return if res.code != 200 || res.mime_type != 'text/html'
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @card = PreviewCard.find_by(url: @url)
+        process_url if @card.nil? || @card.updated_at <= 2.weeks.ago
+      end
+    end
 
-    attempt_opengraph(card, url) unless attempt_oembed(card, url)
+    attach_card if @card&.persisted?
   rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError
     nil
   end
 
   private
 
-  def parse_urls(status)
-    if status.local?
-      urls = status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
+  def process_url
+    @card ||= PreviewCard.new(url: @url)
+    res     = Request.new(:head, @url).perform
+
+    return if res.code != 200 || res.mime_type != 'text/html'
+
+    attempt_oembed || attempt_opengraph
+  end
+
+  def attach_card
+    @status.preview_cards << @card
+  end
+
+  def parse_urls
+    if @status.local?
+      urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).normalize }
     else
-      html  = Nokogiri::HTML(status.text)
+      html  = Nokogiri::HTML(@status.text)
       links = html.css('a')
       urls  = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact
     end
@@ -44,41 +68,41 @@ class FetchLinkCardService < BaseService
     a['rel']&.include?('tag') || a['class']&.include?('u-url')
   end
 
-  def attempt_oembed(card, url)
-    response = OEmbed::Providers.get(url)
+  def attempt_oembed
+    response = OEmbed::Providers.get(@url)
 
-    card.type          = response.type
-    card.title         = response.respond_to?(:title)         ? response.title         : ''
-    card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
-    card.author_url    = response.respond_to?(:author_url)    ? response.author_url    : ''
-    card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
-    card.provider_url  = response.respond_to?(:provider_url)  ? response.provider_url  : ''
-    card.width         = 0
-    card.height        = 0
+    @card.type          = response.type
+    @card.title         = response.respond_to?(:title)         ? response.title         : ''
+    @card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
+    @card.author_url    = response.respond_to?(:author_url)    ? response.author_url    : ''
+    @card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
+    @card.provider_url  = response.respond_to?(:provider_url)  ? response.provider_url  : ''
+    @card.width         = 0
+    @card.height        = 0
 
-    case card.type
+    case @card.type
     when 'link'
-      card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
+      @card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
     when 'photo'
-      card.url    = response.url
-      card.width  = response.width.presence  || 0
-      card.height = response.height.presence || 0
+      @card.url    = response.url
+      @card.width  = response.width.presence  || 0
+      @card.height = response.height.presence || 0
     when 'video'
-      card.width  = response.width.presence  || 0
-      card.height = response.height.presence || 0
-      card.html   = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
+      @card.width  = response.width.presence  || 0
+      @card.height = response.height.presence || 0
+      @card.html   = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
     when 'rich'
       # Most providers rely on <script> tags, which is a no-no
       return false
     end
 
-    card.save_with_optional_image!
+    @card.save_with_optional_image!
   rescue OEmbed::NotFound
     false
   end
 
-  def attempt_opengraph(card, url)
-    response = Request.new(:get, url).perform
+  def attempt_opengraph
+    response = Request.new(:get, @url).perform
 
     return if response.code != 200 || response.mime_type != 'text/html'
 
@@ -88,19 +112,36 @@ class FetchLinkCardService < BaseService
     detector.strip_tags = true
 
     guess = detector.detect(html, response.charset)
-    page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
+    page  = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
+
+    if meta_property(page, 'twitter:player')
+      @card.type   = :video
+      @card.width  = meta_property(page, 'twitter:player:width') || 0
+      @card.height = meta_property(page, 'twitter:player:height') || 0
+      @card.html   = content_tag(:iframe, nil, src: meta_property(page, 'twitter:player'),
+                                               width: @card.width,
+                                               height: @card.height,
+                                               allowtransparency: 'true',
+                                               scrolling: 'no',
+                                               frameborder: '0')
+    else
+      @card.type             = :link
+      @card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
+    end
 
-    card.type             = :link
-    card.title            = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
-    card.description      = meta_property(page, 'og:description') || meta_property(page, 'description')
-    card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
+    @card.title            = meta_property(page, 'og:title').presence || page.at_xpath('//title')&.content || ''
+    @card.description      = meta_property(page, 'og:description').presence || meta_property(page, 'description') || ''
 
-    return if card.title.blank?
+    return if @card.title.blank? && @card.html.blank?
 
-    card.save_with_optional_image!
+    @card.save_with_optional_image!
   end
 
   def meta_property(html, property)
     html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
   end
+
+  def lock_options
+    { redis: Redis.current, key: "fetch:#{@url}" }
+  end
 end
diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb
index 8eed0d454..bd98e70d1 100644
--- a/app/services/fetch_remote_account_service.rb
+++ b/app/services/fetch_remote_account_service.rb
@@ -3,16 +3,20 @@
 class FetchRemoteAccountService < BaseService
   include AuthorExtractor
 
-  def call(url, prefetched_body = nil)
+  def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      atom_url, body = FetchAtomService.new.call(url)
+      resource_url, body, protocol = FetchAtomService.new.call(url)
     else
-      atom_url = url
-      body     = prefetched_body
+      resource_url = url
+      body         = prefetched_body
     end
 
-    return nil if atom_url.nil?
-    process_atom(atom_url, body)
+    case protocol
+    when :ostatus
+      process_atom(resource_url, body)
+    when :activitypub
+      ActivityPub::FetchRemoteAccountService.new.call(resource_url, body)
+    end
   end
 
   private
@@ -21,7 +25,7 @@ class FetchRemoteAccountService < BaseService
     xml = Nokogiri::XML(body)
     xml.encoding = 'utf-8'
 
-    account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: TagManager::XMLNS), false)
+    account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: OStatus::TagManager::XMLNS), false)
 
     UpdateRemoteProfileService.new.call(xml, account) unless account.nil?
 
diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb
index 2c1c1f05f..341664272 100644
--- a/app/services/fetch_remote_resource_service.rb
+++ b/app/services/fetch_remote_resource_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class FetchRemoteResourceService < BaseService
+  include JsonLdHelper
+
   attr_reader :url
 
   def call(url)
@@ -14,11 +16,11 @@ class FetchRemoteResourceService < BaseService
   private
 
   def process_url
-    case xml_root
-    when 'feed'
-      FetchRemoteAccountService.new.call(atom_url, body)
-    when 'entry'
-      FetchRemoteStatusService.new.call(atom_url, body)
+    case type
+    when 'Person'
+      FetchRemoteAccountService.new.call(atom_url, body, protocol)
+    when 'Note'
+      FetchRemoteStatusService.new.call(atom_url, body, protocol)
     end
   end
 
@@ -31,7 +33,26 @@ class FetchRemoteResourceService < BaseService
   end
 
   def body
-    fetched_atom_feed.last
+    fetched_atom_feed.second
+  end
+
+  def protocol
+    fetched_atom_feed.third
+  end
+
+  def type
+    return json_data['type'] if protocol == :activitypub
+
+    case xml_root
+    when 'feed'
+      'Person'
+    when 'entry'
+      'Note'
+    end
+  end
+
+  def json_data
+    @_json_data ||= body_to_json(body)
   end
 
   def xml_root
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index b9f5f97b1..1b90854c4 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -3,16 +3,20 @@
 class FetchRemoteStatusService < BaseService
   include AuthorExtractor
 
-  def call(url, prefetched_body = nil)
+  def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      atom_url, body = FetchAtomService.new.call(url)
+      resource_url, body, protocol = FetchAtomService.new.call(url)
     else
-      atom_url = url
-      body     = prefetched_body
+      resource_url = url
+      body         = prefetched_body
     end
 
-    return nil if atom_url.nil?
-    process_atom(atom_url, body)
+    case protocol
+    when :ostatus
+      process_atom(resource_url, body)
+    when :activitypub
+      ActivityPub::FetchRemoteStatusService.new.call(resource_url, body)
+    end
   end
 
   private
@@ -23,7 +27,7 @@ class FetchRemoteStatusService < BaseService
     xml = Nokogiri::XML(body)
     xml.encoding = 'utf-8'
 
-    account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS))
+    account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS))
     domain  = Addressable::URI.parse(url).normalized_host
 
     return nil unless !account.nil? && confirmed_domain?(domain, account)
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 3155feaa4..791773f25 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -5,16 +5,16 @@ class FollowService < BaseService
 
   # Follow a remote user, notify remote user about the follow
   # @param [Account] source_account From which to follow
-  # @param [String] uri User URI to follow in the form of username@domain
+  # @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
   def call(source_account, uri)
-    target_account = ResolveRemoteAccountService.new.call(uri)
+    target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
     raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account)
 
-    return if source_account.following?(target_account)
+    return if source_account.following?(target_account) || source_account.requested?(target_account)
 
-    if target_account.locked?
+    if target_account.locked? || target_account.activitypub?
       request_follow(source_account, target_account)
     else
       direct_follow(source_account, target_account)
@@ -28,9 +28,11 @@ class FollowService < BaseService
 
     if target_account.local?
       NotifyService.new.call(target_account, follow_request)
-    else
+    elsif target_account.ostatus?
       NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
       AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
+    elsif target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url)
     end
 
     follow_request
@@ -63,4 +65,12 @@ class FollowService < BaseService
   def build_follow_xml(follow)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow))
   end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::FollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.account))
+  end
 end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 92f92cc7d..56cbebd5d 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -1,9 +1,9 @@
 # frozen_string_literal: true
 
 class MuteService < BaseService
-  def call(account, target_account)
+  def call(account, target_account, notifications: nil)
     return if account.id == target_account.id
     FeedManager.instance.clear_from_timeline(account, target_account)
-    account.mute!(target_account)
+    account.mute!(target_account, notifications: notifications)
   end
 end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index ca53c61c5..fb09df983 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -41,7 +41,7 @@ class NotifyService < BaseService
     blocked ||= @recipient.id == @notification.from_account.id                                                                       # Skip for interactions with self
     blocked ||= @recipient.domain_blocking?(@notification.from_account.domain) && !@recipient.following?(@notification.from_account) # Skip for domain blocked accounts
     blocked ||= @recipient.blocking?(@notification.from_account)                                                                     # Skip for blocked accounts
-    blocked ||= @recipient.muting?(@notification.from_account)                                                                       # Skip for muted accounts
+    blocked ||= @recipient.muting_notifications?(@notification.from_account)
     blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account))                         # Hellban
     blocked ||= (@recipient.user.settings.interactions['must_be_follower']  && !@notification.from_account.following?(@recipient))   # Options
     blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account))   # Options
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 0ecd8a9cd..d1b8f42c7 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -27,9 +27,10 @@ class PostStatusService < BaseService
                                         thread: in_reply_to,
                                         sensitive: options[:sensitive],
                                         spoiler_text: options[:spoiler_text] || '',
-                                        visibility: options[:visibility],
-                                        language: detect_language_for(text, account),
+                                        visibility: options[:visibility] || account.user&.setting_default_privacy,
+                                        language: LanguageDetector.instance.detect(text, account),
                                         application: options[:application])
+
       attach_media(status, media)
     end
 
@@ -42,6 +43,8 @@ class PostStatusService < BaseService
     # match both with and without U+FE0F (the emoji variation selector)
     unless /👁\ufe0f?\z/.match?(status.content)
       Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(status.id)
+      ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
     end
 
     if options[:idempotency].present?
@@ -70,10 +73,6 @@ class PostStatusService < BaseService
     media.update(status_id: status.id)
   end
 
-  def detect_language_for(text, account)
-    LanguageDetector.new(text, account).to_iso_s
-  end
-
   def process_mentions_service
     @process_mentions_service ||= ProcessMentionsService.new
   end
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 31191a818..2a5f1e2bc 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -16,7 +16,7 @@ class ProcessFeedService < BaseService
   end
 
   def process_entries(xml, account)
-    xml.xpath('//xmlns:entry', xmlns: TagManager::XMLNS).reverse_each.map { |entry| process_entry(entry, account) }.compact
+    xml.xpath('//xmlns:entry', xmlns: OStatus::TagManager::XMLNS).reverse_each.map { |entry| process_entry(entry, account) }.compact
   end
 
   def process_entry(xml, account)
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index cc99cde03..1fca3832b 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -13,7 +13,7 @@ class ProcessInteractionService < BaseService
     xml = Nokogiri::XML(body)
     xml.encoding = 'utf-8'
 
-    account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS))
+    account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS))
 
     return if account.nil? || account.suspended?
 
@@ -54,23 +54,26 @@ class ProcessInteractionService < BaseService
   private
 
   def mentions_account?(xml, account)
-    xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if [TagManager.instance.uri_for(account), TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) }
+    xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each { |mention_link| return true if [OStatus::TagManager.instance.uri_for(account), OStatus::TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) }
     false
   end
 
   def verb(xml)
-    raw = xml.at_xpath('//activity:verb', activity: TagManager::AS_XMLNS).content
-    TagManager::VERBS.key(raw)
+    raw = xml.at_xpath('//activity:verb', activity: OStatus::TagManager::AS_XMLNS).content
+    OStatus::TagManager::VERBS.key(raw)
   rescue
     :post
   end
 
   def follow!(account, target_account)
     follow = account.follow!(target_account)
+    FollowRequest.find_by(account: account, target_account: target_account)&.destroy
     NotifyService.new.call(target_account, follow)
   end
 
   def follow_request!(account, target_account)
+    return if account.requested?(target_account)
+
     follow_request = FollowRequest.create!(account: account, target_account: target_account)
     NotifyService.new.call(target_account, follow_request)
   end
@@ -88,6 +91,7 @@ class ProcessInteractionService < BaseService
 
   def unfollow!(account, target_account)
     account.unfollow!(target_account)
+    FollowRequest.find_by(account: account, target_account: target_account)&.destroy
   end
 
   def reflect_block!(account, target_account)
@@ -100,7 +104,7 @@ class ProcessInteractionService < BaseService
   end
 
   def delete_post!(xml, account)
-    status = Status.find(xml.at_xpath('//xmlns:id', xmlns: TagManager::XMLNS).content)
+    status = Status.find(xml.at_xpath('//xmlns:id', xmlns: OStatus::TagManager::XMLNS).content)
 
     return if status.nil?
 
@@ -133,12 +137,12 @@ class ProcessInteractionService < BaseService
 
   def status(xml)
     uri = activity_id(xml)
-    return nil unless TagManager.instance.local_id?(uri)
-    Status.find(TagManager.instance.unique_tag_to_local_id(uri, 'Status'))
+    return nil unless OStatus::TagManager.instance.local_id?(uri)
+    Status.find(OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status'))
   end
 
   def activity_id(xml)
-    xml.at_xpath('//activity:object', activity: TagManager::AS_XMLNS).at_xpath('./xmlns:id', xmlns: TagManager::XMLNS).content
+    xml.at_xpath('//activity:object', activity: OStatus::TagManager::AS_XMLNS).at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content
   end
 
   def salmon
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 438033d22..1c3eea369 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -28,18 +28,32 @@ class ProcessMentionsService < BaseService
     end
 
     status.mentions.includes(:account).each do |mention|
-      mentioned_account = mention.account
-
-      if mentioned_account.local?
-        NotifyService.new.call(mentioned_account, mention)
-      else
-        NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
-      end
+      create_notification(status, mention)
     end
   end
 
   private
 
+  def create_notification(status, mention)
+    mentioned_account = mention.account
+
+    if mentioned_account.local?
+      NotifyService.new.call(mentioned_account, mention)
+    elsif mentioned_account.ostatus? && !status.stream_entry.hidden?
+      NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
+    elsif mentioned_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url)
+    end
+  end
+
+  def build_json(status)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      status,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(status.account))
+  end
+
   def follow_remote_account_service
     @follow_remote_account_service ||= ResolveRemoteAccountService.new
   end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 497cdb4f5..52e3ba0e0 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -20,17 +20,35 @@ class ReblogService < BaseService
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
     DistributionWorker.perform_async(reblog.id)
+
     unless /👁$/.match?(reblogged_status.content)
       Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(reblog.id)
     end
 
+    create_notification(reblog)
+    reblog
+  end
+
+  private
 
-    if reblogged_status.local?
-      NotifyService.new.call(reblog.reblog.account, reblog)
-    else
-      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id)
+  def create_notification(reblog)
+    reblogged_status = reblog.reblog
+
+    if reblogged_status.account.local?
+      NotifyService.new.call(reblogged_status.account, reblog)
+    elsif reblogged_status.account.ostatus?
+      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id)
+    elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
+      ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
     end
+  end
 
-    reblog
+  def build_json(reblog)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      reblog,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(reblog.account))
   end
 end
diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb
index fd7e66c23..c1f7bcb60 100644
--- a/app/services/reject_follow_service.rb
+++ b/app/services/reject_follow_service.rb
@@ -4,11 +4,28 @@ class RejectFollowService < BaseService
   def call(source_account, target_account)
     follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
     follow_request.reject!
-    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+    create_notification(follow_request) unless source_account.local?
+    follow_request
   end
 
   private
 
+  def create_notification(follow_request)
+    if follow_request.account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
+    elsif follow_request.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
+    end
+  end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::RejectFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.target_account))
+  end
+
   def build_xml(follow_request)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request))
   end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index a5281f586..14f24908c 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -4,7 +4,7 @@ class RemoveStatusService < BaseService
   include StreamEntryRenderer
 
   def call(status)
-    @payload      = Oj.dump(event: :delete, payload: status.id)
+    @payload      = Oj.dump(event: :delete, payload: status.id.to_s)
     @status       = status
     @account      = status.account
     @tags         = status.tags.pluck(:name).to_a
@@ -14,6 +14,7 @@ class RemoveStatusService < BaseService
 
     remove_from_self if status.account.local?
     remove_from_followers
+    remove_from_affected
     remove_reblogs
     remove_from_hashtags
     remove_from_public
@@ -22,8 +23,8 @@ class RemoveStatusService < BaseService
 
     return unless @account.local?
 
-    remove_from_mentioned(@stream_entry.reload)
-    Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id)
+    remove_from_remote_followers
+    remove_from_remote_affected
   end
 
   private
@@ -38,15 +39,58 @@ class RemoveStatusService < BaseService
     end
   end
 
-  def remove_from_mentioned(stream_entry)
-    salmon_xml       = stream_entry_to_xml(stream_entry)
-    target_accounts  = @mentions.map(&:account).reject(&:local?).uniq(&:domain)
+  def remove_from_affected
+    @mentions.map(&:account).select(&:local?).each do |account|
+      Redis.current.publish("timeline:#{account.id}", @payload)
+    end
+  end
+
+  def remove_from_remote_affected
+    # People who got mentioned in the status, or who
+    # reblogged it from someone else might not follow
+    # the author and wouldn't normally receive the
+    # delete notification - so here, we explicitly
+    # send it to them
+
+    target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?)).uniq(&:id)
+
+    # Ostatus
+    NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account|
+      [salmon_xml, @account.id, target_account.id]
+    end
+
+    # ActivityPub
+    ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |target_account|
+      [signed_activity_json, @account.id, target_account.inbox_url]
+    end
+  end
 
-    NotificationWorker.push_bulk(target_accounts) do |target_account|
-      [salmon_xml, stream_entry.account_id, target_account.id]
+  def remove_from_remote_followers
+    # OStatus
+    Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id)
+
+    # ActivityPub
+    ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
+      [signed_activity_json, @account.id, inbox_url]
     end
   end
 
+  def salmon_xml
+    @salmon_xml ||= stream_entry_to_xml(@stream_entry)
+  end
+
+  def signed_activity_json
+    @signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account))
+  end
+
+  def activity_json
+    @activity_json ||= ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+
   def remove_reblogs
     # We delete reblogs of the status before the original status,
     # because once original status is gone, reblogs will disappear
@@ -68,6 +112,8 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_hashtags
+    return unless @status.public_visibility?
+
     @tags.each do |hashtag|
       Redis.current.publish("timeline:hashtag:#{hashtag}", @payload)
       Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local?
@@ -75,6 +121,8 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_public
+    return unless @status.public_visibility?
+
     Redis.current.publish('timeline:public', @payload)
     Redis.current.publish('timeline:public:local', @payload) if @status.local?
   end
diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb
index e0e2ebc83..57c80fc82 100644
--- a/app/services/resolve_remote_account_service.rb
+++ b/app/services/resolve_remote_account_service.rb
@@ -2,6 +2,7 @@
 
 class ResolveRemoteAccountService < BaseService
   include OStatus2::MagicKey
+  include JsonLdHelper
 
   DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 
@@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService
   # @return [Account]
   def call(uri, update_profile = true, redirected = nil)
     @username, @domain = uri.split('@')
+    @update_profile    = update_profile
 
     return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
 
@@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService
       if lock.acquired?
         @account = Account.find_remote(@username, @domain)
 
-        create_account if @account.nil?
-        update_account
-
-        update_account_profile if update_profile
+        if activitypub_ready?
+          handle_activitypub
+        else
+          handle_ostatus
+        end
       end
     end
 
@@ -58,18 +61,47 @@ class ResolveRemoteAccountService < BaseService
   private
 
   def links_missing?
-    @webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
+    !(activitypub_ready? || ostatus_ready?)
+  end
+
+  def ostatus_ready?
+    !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
       @webfinger.link('salmon').nil? ||
       @webfinger.link('http://webfinger.net/rel/profile-page').nil? ||
       @webfinger.link('magic-public-key').nil? ||
       canonical_uri.nil? ||
-      hub_url.nil?
+      hub_url.nil?)
   end
 
   def webfinger_update_due?
     @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago
   end
 
+  def activitypub_ready?
+    !@webfinger.link('self').nil? &&
+      ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) &&
+      !actor_json.nil? &&
+      actor_json['inbox'].present?
+  end
+
+  def handle_ostatus
+    create_account if @account.nil?
+    update_account
+    update_account_profile if update_profile?
+  end
+
+  def update_profile?
+    @update_profile
+  end
+
+  def handle_activitypub
+    return if actor_json.nil?
+
+    @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json)
+  rescue Oj::ParseError
+    nil
+  end
+
   def create_account
     Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
 
@@ -81,6 +113,7 @@ class ResolveRemoteAccountService < BaseService
 
   def update_account
     @account.last_webfingered_at = Time.now.utc
+    @account.protocol            = :ostatus
     @account.remote_url          = atom_url
     @account.salmon_url          = salmon_url
     @account.url                 = url
@@ -111,6 +144,10 @@ class ResolveRemoteAccountService < BaseService
     @salmon_url ||= @webfinger.link('salmon').href
   end
 
+  def actor_url
+    @actor_url ||= @webfinger.link('self').href
+  end
+
   def url
     @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href
   end
@@ -149,6 +186,13 @@ class ResolveRemoteAccountService < BaseService
     @atom_body = response.to_s
   end
 
+  def actor_json
+    return @actor_json if defined?(@actor_json)
+
+    json        = fetch_resource(actor_url)
+    @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil
+  end
+
   def atom
     return @atom if defined?(@atom)
     @atom = Nokogiri::XML(atom_body)
diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb
index d3e41e691..bfa7ff8c8 100644
--- a/app/services/subscribe_service.rb
+++ b/app/services/subscribe_service.rb
@@ -2,7 +2,7 @@
 
 class SubscribeService < BaseService
   def call(account)
-    return unless account.ostatus?
+    return if account.hub_url.blank?
 
     @account        = account
     @account.secret = SecureRandom.hex
@@ -42,7 +42,7 @@ class SubscribeService < BaseService
   end
 
   def some_local_account
-    @some_local_account ||= Account.local.first
+    @some_local_account ||= Account.local.where(suspended: false).first
   end
 
   # Any response in the 3xx or 4xx range, except for 429 (rate limit)
diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb
index ff15c7275..869f62d1c 100644
--- a/app/services/unblock_service.rb
+++ b/app/services/unblock_service.rb
@@ -5,11 +5,28 @@ class UnblockService < BaseService
     return unless account.blocking?(target_account)
 
     unblock = account.unblock!(target_account)
-    NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local?
+    create_notification(unblock) unless target_account.local?
+    unblock
   end
 
   private
 
+  def create_notification(unblock)
+    if unblock.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id)
+    elsif unblock.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url)
+    end
+  end
+
+  def build_json(unblock)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      unblock,
+      serializer: ActivityPub::UndoBlockSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(unblock.account))
+  end
+
   def build_xml(block)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block))
   end
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index 564aaee46..2fda11bd6 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -4,14 +4,30 @@ class UnfavouriteService < BaseService
   def call(account, status)
     favourite = Favourite.find_by!(account: account, status: status)
     favourite.destroy!
-
-    NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local?
-
+    create_notification(favourite) unless status.local?
     favourite
   end
 
   private
 
+  def create_notification(favourite)
+    status = favourite.status
+
+    if status.account.ostatus?
+      NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
+    elsif status.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
+    end
+  end
+
+  def build_json(favourite)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      favourite,
+      serializer: ActivityPub::UndoLikeSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(favourite.account))
+  end
+
   def build_xml(favourite)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite))
   end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 388909586..73a64929f 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -5,14 +5,51 @@ class UnfollowService < BaseService
   # @param [Account] source_account Where to unfollow from
   # @param [Account] target_account Which to unfollow
   def call(source_account, target_account)
-    follow = source_account.unfollow!(target_account)
-    return unless follow
-    NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local?
-    UnmergeWorker.perform_async(target_account.id, source_account.id)
+    @source_account = source_account
+    @target_account = target_account
+
+    unfollow! || undo_follow_request!
   end
 
   private
 
+  def unfollow!
+    follow = Follow.find_by(account: @source_account, target_account: @target_account)
+
+    return unless follow
+
+    follow.destroy!
+    create_notification(follow) unless @target_account.local?
+    UnmergeWorker.perform_async(@target_account.id, @source_account.id)
+    follow
+  end
+
+  def undo_follow_request!
+    follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account)
+
+    return unless follow_request
+
+    follow_request.destroy!
+    create_notification(follow_request) unless @target_account.local?
+    follow_request
+  end
+
+  def create_notification(follow)
+    if follow.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id)
+    elsif follow.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url)
+    end
+  end
+
+  def build_json(follow)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow,
+      serializer: ActivityPub::UndoFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow.account))
+  end
+
   def build_xml(follow)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow))
   end
diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb
index c5e0e73fe..b99046712 100644
--- a/app/services/unsubscribe_service.rb
+++ b/app/services/unsubscribe_service.rb
@@ -2,18 +2,21 @@
 
 class UnsubscribeService < BaseService
   def call(account)
-    return unless account.ostatus?
+    return if account.hub_url.blank?
 
-    @account  = account
-    @response = build_request.perform
+    @account = account
 
-    Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success?
+    begin
+      @response = build_request.perform
+
+      Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success?
+    rescue HTTP::Error, OpenSSL::SSL::SSLError => e
+      Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{e}"
+    end
 
     @account.secret = ''
     @account.subscription_expires_at = nil
     @account.save!
-  rescue HTTP::Error, OpenSSL::SSL::SSLError
-    Rails.logger.debug "PuSH subscription request for #{@account.acct} could not be made due to HTTP or SSL error"
   end
 
   private
diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb
new file mode 100644
index 000000000..09ea377e7
--- /dev/null
+++ b/app/services/update_account_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class UpdateAccountService < BaseService
+  def call(account, params, raise_error: false)
+    was_locked = account.locked
+    update_method = raise_error ? :update! : :update
+    account.send(update_method, params).tap do |ret|
+      next unless ret
+      authorize_all_follow_requests(account) if was_locked && !account.locked
+    end
+  end
+
+  private
+
+  def authorize_all_follow_requests(account)
+    follow_requests = FollowRequest.where(target_account: account)
+    AuthorizeFollowWorker.push_bulk(follow_requests) do |req|
+      [req.account_id, req.target_account_id]
+    end
+  end
+end
diff --git a/app/services/verify_salmon_service.rb b/app/services/verify_salmon_service.rb
index cd674837d..205b35d8b 100644
--- a/app/services/verify_salmon_service.rb
+++ b/app/services/verify_salmon_service.rb
@@ -9,7 +9,7 @@ class VerifySalmonService < BaseService
     xml = Nokogiri::XML(body)
     xml.encoding = 'utf-8'
 
-    account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS))
+    account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS))
 
     if account.nil?
       false
diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb
new file mode 100644
index 000000000..9760e1138
--- /dev/null
+++ b/app/validators/status_pin_validator.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class StatusPinValidator < ActiveModel::Validator
+  def validate(pin)
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog?
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility)
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count > 4
+  end
+end
diff --git a/app/views/about/_og.html.haml b/app/views/about/_og.html.haml
new file mode 100644
index 000000000..dbd476915
--- /dev/null
+++ b/app/views/about/_og.html.haml
@@ -0,0 +1,10 @@
+- thumbnail = @instance_presenter.thumbnail
+= opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname)
+= opengraph 'og:url', about_url
+= opengraph 'og:type', 'website'
+= opengraph 'og:title', @instance_presenter.site_title
+= opengraph 'og:description', strip_tags(@instance_presenter.site_description.presence || t('about.about_mastodon_html'))
+= opengraph 'og:image', full_asset_url(thumbnail&.file&.url || asset_pack_path('preview.jpg', protocol: :request))
+= opengraph 'og:image:width', thumbnail ? thumbnail.meta['width'] : '1200'
+= opengraph 'og:image:height', thumbnail ? thumbnail.meta['height'] : '630'
+= opengraph 'twitter:card', 'summary_large_image'
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index f1c6e6b9d..7a28f9738 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -1,26 +1,13 @@
 = simple_form_for(new_user, url: user_registration_path) do |f|
   = f.simple_fields_for :account do |account_fields|
     .input-with-append
-      = account_fields.input :username,
-        autofocus: true,
-        placeholder: t('simple_form.labels.defaults.username'),
-        required: true,
-        input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }
+      = account_fields.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off' }
       .append
         = "@#{site_hostname}"
 
-  = f.input :email,
-    placeholder: t('simple_form.labels.defaults.email'),
-    required: true,
-    input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
-  = f.input :password,
-    placeholder: t('simple_form.labels.defaults.password'),
-    required: true,
-    input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
-  = f.input :password_confirmation,
-    placeholder: t('simple_form.labels.defaults.confirm_password'),
-    required: true,
-    input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
+  = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }
+  = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
+  = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
 
   .actions
     = f.button :button, t('auth.register'), type: :submit, class: 'button button-alternative'
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 6d621ce8b..6e4d0cdd1 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -3,16 +3,7 @@
 
 - content_for :header_tags do
   = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-
-  %meta{ property: 'og:site_name', content: site_title }/
-  %meta{ property: 'og:url', content: about_url }/
-  %meta{ property: 'og:type', content: 'website' }/
-  %meta{ property: 'og:title', content: site_hostname }/
-  %meta{ property: 'og:description', content: strip_tags(@instance_presenter.site_description.presence || t('about.about_mastodon_html')) }/
-  %meta{ property: 'og:image', content: asset_pack_path('mastodon_small.jpg', protocol: :request) }/
-  %meta{ property: 'og:image:width', content: '400' }/
-  %meta{ property: 'og:image:height', content: '400' }/
-  %meta{ property: 'twitter:card', content: 'summary' }/
+  = render partial: 'og'
 
 .landing-page
   .header-wrapper.compact
@@ -63,9 +54,9 @@
   .footer-links
     .container
       %p
-        = link_to t('about.source_code'), 'https://github.com/glitch-soc/mastodon'
+        = link_to t('about.source_code'), @instance_presenter.source_url
         - if @instance_presenter.commit_hash == ""
-          %strong= @instance_presenter.version_number
+          %strong= " (#{@instance_presenter.version_number})"
         - else
           %strong= "#{@instance_presenter.version_number}, "
           %strong= "#{@instance_presenter.commit_hash}"
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 3e04dd038..c0fa944ae 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -4,16 +4,7 @@
 - content_for :header_tags do
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
   = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
-
-  %meta{ property: 'og:site_name', content: site_title }/
-  %meta{ property: 'og:url', content: about_url }/
-  %meta{ property: 'og:type', content: 'website' }/
-  %meta{ property: 'og:title', content: site_hostname }/
-  %meta{ property: 'og:description', content: strip_tags(@instance_presenter.site_description.presence || t('about.about_mastodon_html')) }/
-  %meta{ property: 'og:image', content: asset_pack_path('mastodon_small.jpg', protocol: :request) }/
-  %meta{ property: 'og:image:width', content: '400' }/
-  %meta{ property: 'og:image:height', content: '400' }/
-  %meta{ property: 'twitter:card', content: 'summary' }/
+  = render partial: 'og'
 
 .landing-page
   .header-wrapper
@@ -56,9 +47,16 @@
                 %p= t('about.closed_registrations')
               - else
                 = @instance_presenter.closed_registrations_message.html_safe
+
+            = simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) do |f|
+              = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
+              = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
+
+              .actions
+                = f.button :button, t('auth.login'), type: :submit
             = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block'
 
-  .learn-more-cta
+  .about-short
     .container
       %h3= t('about.description_headline', domain: site_hostname)
       %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
@@ -76,7 +74,7 @@
   .footer-links
     .container
       %p
-        = link_to t('about.source_code'), 'https://github.com/glitch-soc/mastodon'
+        = link_to t('about.source_code'), @instance_presenter.source_url
         - if @instance_presenter.commit_hash == ""
           %strong= " (#{@instance_presenter.version_number})"
         - else
diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml
index 0571d1d5e..305eb2c44 100644
--- a/app/views/accounts/_grid_card.html.haml
+++ b/app/views/accounts/_grid_card.html.haml
@@ -1,8 +1,9 @@
 .account-grid-card
-  .account-grid-card__header
+  .account-grid-card__header{ style: "background-image: url(#{account.header.url(:original)})" }
+  .account-grid-card__avatar
     .avatar= image_tag account.avatar.url(:original)
-    .name
-      = link_to TagManager.instance.url_for(account) do
-        %span.display_name.emojify= display_name(account)
-        %span.username @#{account.acct}
+  .name
+    = link_to TagManager.instance.url_for(account) do
+      %span.display_name.emojify= display_name(account)
+      %span.username @#{account.acct}
   %p.note.emojify= truncate(strip_tags(account.note), length: 150)
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index ed8a6f091..dcc6661ba 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -1,41 +1,57 @@
 - processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account
 .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" }
-  .details
+  .card__illustration
     - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
       .controls
         - if current_account.following?(account)
-          = link_to t('accounts.unfollow'), account_unfollow_path(account), data: { method: :post }, class: 'button'
+          = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do
+            = fa_icon 'user-times'
+            = t('accounts.unfollow')
         - else
-          = link_to t('accounts.follow'), account_follow_path(account), data: { method: :post }, class: 'button'
+          = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
+            = fa_icon 'user-plus'
+            = t('accounts.follow')
     - elsif !user_signed_in?
       .controls
         .remote-follow
-          = link_to t('accounts.remote_follow'), account_remote_follow_path(account), class: 'button'
+          = link_to account_remote_follow_path(account), class: 'icon-button' do
+            = fa_icon 'user-plus'
+            = t('accounts.remote_follow')
+
     .avatar= image_tag account.avatar.url(:original), class: 'u-photo'
+
+  .card__bio
     %h1.name
       %span.p-name.emojify= display_name(account)
       %small
-        %span @#{account.username}
+        %span @#{account.local_username_and_domain}
         = fa_icon('lock') if account.locked?
+
+    - if account.user_admin?
+      .roles
+        .account-role
+          = t 'accounts.roles.admin'
     .bio
       .account__header__content.p-note.emojify!=processed_bio[:text]
+      - if processed_bio[:metadata].length > 0
+        .metadata<
+          - processed_bio[:metadata].each do |i|
+            .metadata-item><
+              %b.emojify>!=i[0]
+              %span.emojify>!=i[1]
 
     .details-counters
       .counter{ class: active_nav_class(short_account_url(account)) }
         = link_to short_account_url(account), class: 'u-url u-uid' do
+          %span.counter-number= number_to_human account.statuses_count, strip_insignificant_zeros: true
           %span.counter-label= t('accounts.posts')
-          %span.counter-number= number_with_delimiter account.statuses_count
+
       .counter{ class: active_nav_class(account_following_index_url(account)) }
         = link_to account_following_index_url(account) do
+          %span.counter-number= number_to_human account.following_count, strip_insignificant_zeros: true
           %span.counter-label= t('accounts.following')
-          %span.counter-number= number_with_delimiter account.following_count
+
       .counter{ class: active_nav_class(account_followers_url(account)) }
         = link_to account_followers_url(account) do
+          %span.counter-number= number_to_human account.followers_count, strip_insignificant_zeros: true
           %span.counter-label= t('accounts.followers')
-          %span.counter-number= number_with_delimiter account.followers_count
-  - if processed_bio[:metadata].length > 0
-    .metadata<
-      - processed_bio[:metadata].each do |i|
-        .metadata-item><
-          %b.emojify>!=i[0]
-          %span.emojify>!=i[1]
diff --git a/app/views/accounts/_og.html.haml b/app/views/accounts/_og.html.haml
index 3ad39f391..1d16be590 100644
--- a/app/views/accounts/_og.html.haml
+++ b/app/views/accounts/_og.html.haml
@@ -1,8 +1,9 @@
-%meta{ property: 'og:url', content: url }/
-%meta{ property: 'og:site_name', content: site_title }/
-%meta{ property: 'og:title', content: [yield(:page_title).strip.presence, site_title].compact.join(' - ') }/
-%meta{ property: 'og:description', content: account.note }/
-%meta{ property: 'og:image', content: full_asset_url(account.avatar.url(:original)) }/
-%meta{ property: 'og:image:width', content: '120' }/
-%meta{ property: 'og:image:height', content: '120' }/
-%meta{ property: 'twitter:card', content: 'summary' }/
+= opengraph 'og:url', url
+= opengraph 'og:site_name', site_title
+= opengraph 'og:title', [yield(:page_title).strip.presence, site_title].compact.join(' - ')
+= opengraph 'og:description', account.note
+= opengraph 'og:image', full_asset_url(account.avatar.url(:original))
+= opengraph 'og:image:width', '120'
+= opengraph 'og:image:height', '120'
+= opengraph 'twitter:card', 'summary'
+= opengraph 'profile:username', account.local_username_and_domain
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 150c14791..6c90b2c04 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -7,8 +7,9 @@
 
   %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
   %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) }/
 
-  %meta{ property: 'og:type', content: 'profile' }/
+  = opengraph 'og:type', 'profile'
   = render 'og', account: @account, url: short_account_url(@account, only_path: false)
 
 - if show_landing_strip?
@@ -19,13 +20,21 @@
 
   = render 'header', account: @account
 
+  .activity-stream-tabs
+    = active_link_to t('accounts.posts'), short_account_url(@account)
+    = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account)
+    = active_link_to t('accounts.media'), short_account_media_url(@account)
+
   - if @statuses.empty?
     .accounts-grid
       = render 'nothing_here'
   - else
     .activity-stream.with-header
+      - if params[:page].to_i.zero?
+        = render partial: 'stream_entries/status', collection: @pinned_statuses, as: :status, locals: { pinned: true }
+
       = render partial: 'stream_entries/status', collection: @statuses, as: :status
 
   - if @statuses.size == 20
     .pagination
-      = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next', rel: 'next'
+      = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), @next_url, class: 'next', rel: 'next'
diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml
index c513776b7..5265d77f6 100644
--- a/app/views/admin/accounts/_account.html.haml
+++ b/app/views/admin/accounts/_account.html.haml
@@ -4,6 +4,9 @@
   %td.domain
     - unless account.local?
       = link_to account.domain, admin_accounts_path(by_domain: account.domain)
+  %td.protocol
+    - unless account.local?
+      = account.protocol.humanize
   %td.confirmed
     - if account.local?
       - if account.user_confirmed?
diff --git a/app/views/admin/accounts/_card.html.haml b/app/views/admin/accounts/_card.html.haml
index bb33582eb..2f5955011 100644
--- a/app/views/admin/accounts/_card.html.haml
+++ b/app/views/admin/accounts/_card.html.haml
@@ -1,16 +1,17 @@
-%table.table
-  %tbody
-    %tr
-      %td= t('admin.accounts.show.created_reports')
-      %td= link_to pluralize(account.reports.count, t('admin.accounts.show.report')), admin_reports_path(account_id: account.id)
-    %tr
-      %td= t('admin.accounts.show.targeted_reports')
-      %td= link_to pluralize(account.targeted_reports.count, t('admin.accounts.show.report')), admin_reports_path(target_account_id: account.id)
-    - if account.silenced? || account.suspended?
+.table-wrapper
+  %table.table
+    %tbody
       %tr
-        %td= t('admin.accounts.moderation.title')
-        %td
-          - if account.silenced?
-            %p= t('admin.accounts.moderation.silenced')
-          - if account.suspended?
-            %p= t('admin.accounts.moderation.suspended')
+        %td= t('admin.accounts.show.created_reports')
+        %td= link_to pluralize(account.reports.count, t('admin.accounts.show.report')), admin_reports_path(account_id: account.id)
+      %tr
+        %td= t('admin.accounts.show.targeted_reports')
+        %td= link_to pluralize(account.targeted_reports.count, t('admin.accounts.show.report')), admin_reports_path(target_account_id: account.id)
+      - if account.silenced? || account.suspended?
+        %tr
+          %td= t('admin.accounts.moderation.title')
+          %td
+            - if account.silenced?
+              %p= t('admin.accounts.moderation.silenced')
+            - if account.suspended?
+              %p= t('admin.accounts.moderation.suspended')
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 07c8d1632..1b56a3a31 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -50,15 +50,17 @@
       %button= t('admin.accounts.search')
       = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
 
-%table.table
-  %thead
-    %tr
-      %th= t('admin.accounts.username')
-      %th= t('admin.accounts.domain')
-      %th= t('admin.accounts.confirmed')
-      %th= fa_icon 'paper-plane-o'
-      %th
-  %tbody
-    = render @accounts
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.accounts.username')
+        %th= t('admin.accounts.domain')
+        %th= t('admin.accounts.protocol')
+        %th= t('admin.accounts.confirmed')
+        %th= fa_icon 'paper-plane-o'
+        %th
+    %tbody
+      = render @accounts
 
 = paginate @accounts
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 5ad1fd6ee..3775b6721 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -1,95 +1,131 @@
 - content_for :page_title do
   = @account.acct
 
-%table.table
-  %tbody
-    %tr
-      %th= t('admin.accounts.username')
-      %td= @account.username
-    %tr
-      %th= t('admin.accounts.domain')
-      %td= @account.domain
-    %tr
-      %th= t('admin.accounts.display_name')
-      %td= @account.display_name
-
-    - if @account.local?
+.table-wrapper
+  %table.table
+    %tbody
       %tr
-        %th= t('admin.accounts.email')
-        %td= @account.user_email
+        %th= t('admin.accounts.username')
+        %td= @account.username
       %tr
-        %th= t('admin.accounts.most_recent_ip')
-        %td= @account.user_current_sign_in_ip
+        %th= t('admin.accounts.domain')
+        %td= @account.domain
       %tr
-        %th= t('admin.accounts.most_recent_activity')
-        %td
-          - if @account.user_current_sign_in_at
-            = l @account.user_current_sign_in_at
-          - else
-            Never
-    - else
+        %th= t('admin.accounts.display_name')
+        %td= @account.display_name
+
+      - if @account.local?
+        %tr
+          %th= t('admin.accounts.email')
+          %td= @account.user_email
+        %tr
+          %th= t('admin.accounts.most_recent_ip')
+          %td= @account.user_current_sign_in_ip
+        %tr
+          %th= t('admin.accounts.most_recent_activity')
+          %td
+            - if @account.user_current_sign_in_at
+              %time.formatted{ datetime: @account.user_current_sign_in_at.iso8601, title: l(@account.user_current_sign_in_at) }
+                = l @account.user_current_sign_in_at
+            - else
+              Never
+      - else
+        %tr
+          %th= t('admin.accounts.profile_url')
+          %td= link_to @account.url, @account.url
+        %tr
+          %th= t('admin.accounts.protocol')
+          %td= @account.protocol.humanize
+
       %tr
-        %th= t('admin.accounts.profile_url')
-        %td= link_to @account.url, @account.url
+        %th= t('admin.accounts.follows')
+        %td= @account.following_count
       %tr
-        %th= t('admin.accounts.feed_url')
-        %td= link_to @account.remote_url, @account.remote_url
+        %th= t('admin.accounts.followers')
+        %td= @account.followers_count
       %tr
-        %th= t('admin.accounts.push_subscription_expires')
+        %th= t('admin.accounts.statuses')
+        %td= link_to @account.statuses_count, admin_account_statuses_path(@account.id)
+      %tr
+        %th= t('admin.accounts.media_attachments')
         %td
-          - if @account.subscribed?
-            = l @account.subscription_expires_at
-          - else
-            = t('admin.accounts.not_subscribed')
+          = link_to @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
+        %th= t('.created_reports')
+        %td= link_to pluralize(@account.reports.count, t('.report')), admin_reports_path(account_id: @account.id)
       %tr
-        %th= t('admin.accounts.salmon_url')
-        %td= link_to @account.salmon_url, @account.salmon_url
+        %th= t('.targeted_reports')
+        %td= link_to pluralize(@account.targeted_reports.count, t('.report')), admin_reports_path(target_account_id: @account.id)
 
-    %tr
-      %th= t('admin.accounts.follows')
-      %td= @account.following_count
-    %tr
-      %th= t('admin.accounts.followers')
-      %td= @account.followers_count
-    %tr
-      %th= t('admin.accounts.statuses')
-      %td= link_to @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 })
-        = surround '(', ')' do
-          = number_to_human_size @account.media_attachments.sum('file_file_size')
-    %tr
-      %th= t('.created_reports')
-      %td= link_to pluralize(@account.reports.count, t('.report')), admin_reports_path(account_id: @account.id)
-    %tr
-      %th= t('.targeted_reports')
-      %td= link_to pluralize(@account.targeted_reports.count, t('.report')), admin_reports_path(target_account_id: @account.id)
+%div{ style: 'overflow: hidden' }
+  %div{ style: 'float: right' }
+    - if @account.local?
+      = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button'
+      - if @account.user&.otp_required_for_login?
+        = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button'
+    - else
+      = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button'
+
+  %div{ style: 'float: left' }
+    - if @account.silenced?
+      = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button'
+    - else
+      = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button'
+
+    - if @account.local?
+      - unless @account.user_confirmed?
+        = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button'
+
+    - if @account.suspended?
+      = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button'
+    - else
+      = 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'
 
+- unless @account.local?
+  %hr
+  %h3 OStatus
 
-%div{ style: 'float: right' }
-  - if @account.local?
-    = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button'
-    - if @account.user&.otp_required_for_login?
-      = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button'
-  - else
-    = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button'
-    - if @account.subscribed?
-      = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative'
-    = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button'
+  .table-wrapper
+    %table.table
+      %tbody
+        %tr
+          %th= t('admin.accounts.feed_url')
+          %td= link_to @account.remote_url, @account.remote_url
+        %tr
+          %th= t('admin.accounts.push_subscription_expires')
+          %td
+            - if @account.subscribed?
+              %time.formatted{ datetime: @account.subscription_expires_at.iso8601, title: l(@account.subscription_expires_at) }
+                = l @account.subscription_expires_at
+            - else
+              = t('admin.accounts.not_subscribed')
+        %tr
+          %th= t('admin.accounts.salmon_url')
+          %td= link_to @account.salmon_url, @account.salmon_url
 
-%div{ style: 'float: left' }
-  - if @account.silenced?
-    = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button'
-  - else
-    = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button'
+  %div{ style: 'overflow: hidden' }
+    %div{ style: 'float: right' }
+      = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button'
+      - if @account.subscribed?
+        = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative'
 
-  - if @account.local?
-    - unless @account.user_confirmed?
-      = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button'
+  %hr
+  %h3 ActivityPub
 
-  - if @account.suspended?
-    = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button'
-  - else
-    = 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'
+  .table-wrapper
+    %table.table
+      %tbody
+        %tr
+          %th= t('admin.accounts.inbox_url')
+          %td= link_to @account.inbox_url, @account.inbox_url
+        %tr
+          %th= t('admin.accounts.outbox_url')
+          %td= link_to @account.outbox_url, @account.outbox_url
+        %tr
+          %th= t('admin.accounts.shared_inbox_url')
+          %td= link_to @account.shared_inbox_url, @account.shared_inbox_url
+        %tr
+          %th= t('admin.accounts.followers_url')
+          %td= link_to @account.followers_url, @account.followers_url
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml
new file mode 100644
index 000000000..ff1aa9925
--- /dev/null
+++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml
@@ -0,0 +1,7 @@
+%tr
+  %td
+    = image_tag custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:"
+  %td
+    %samp= ":#{custom_emoji.shortcode}:"
+  %td
+    = table_link_to 'times', t('admin.custom_emojis.delete'), admin_custom_emoji_path(custom_emoji), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml
new file mode 100644
index 000000000..d5f32e84b
--- /dev/null
+++ b/app/views/admin/custom_emojis/index.html.haml
@@ -0,0 +1,14 @@
+- content_for :page_title do
+  = t('admin.custom_emojis.title')
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.custom_emojis.emoji')
+        %th= t('admin.custom_emojis.shortcode')
+        %th
+    %tbody
+      = render @custom_emojis
+
+= link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/admin/custom_emojis/new.html.haml
new file mode 100644
index 000000000..672afe435
--- /dev/null
+++ b/app/views/admin/custom_emojis/new.html.haml
@@ -0,0 +1,12 @@
+- content_for :page_title do
+  = t('.title')
+
+= simple_form_for @custom_emoji, url: admin_custom_emojis_path do |f|
+  = render 'shared/error_messages', object: @custom_emoji
+
+  .fields-group
+    = f.input :shortcode, placeholder: t('admin.custom_emojis.shortcode'), hint: t('admin.custom_emojis.shortcode_hint')
+    = f.input :image, input_html: { accept: 'image/png' }, hint: t('admin.custom_emojis.image_hint')
+
+  .actions
+    = f.button :button, t('admin.custom_emojis.upload'), type: :submit
diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml
index 5ae9fec53..42b8ca53f 100644
--- a/app/views/admin/domain_blocks/index.html.haml
+++ b/app/views/admin/domain_blocks/index.html.haml
@@ -1,15 +1,16 @@
 - content_for :page_title do
   = t('admin.domain_blocks.title')
 
-%table.table
-  %thead
-    %tr
-      %th= t('admin.domain_blocks.domain')
-      %th= t('admin.domain_blocks.severity')
-      %th= t('admin.domain_blocks.reject_media')
-      %th
-  %tbody
-    = render @domain_blocks
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.domain_blocks.domain')
+        %th= t('admin.domain_blocks.severity')
+        %th= t('admin.domain_blocks.reject_media')
+        %th
+    %tbody
+      = render @domain_blocks
 
 = paginate @domain_blocks
 = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
diff --git a/app/views/admin/instances/_instance.html.haml b/app/views/admin/instances/_instance.html.haml
index 435cd8f64..6efbbbe60 100644
--- a/app/views/admin/instances/_instance.html.haml
+++ b/app/views/admin/instances/_instance.html.haml
@@ -1,6 +1,6 @@
 %tr
   %td.domain
-    = instance.domain
+    = link_to instance.domain, admin_accounts_path(by_domain: instance.domain)
   %td.count
     = instance.accounts_count
   %td
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index be21d6bf7..3314ce077 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -1,12 +1,23 @@
 - content_for :page_title do
   = t('admin.instances.title')
 
-%table.table
-  %thead
-    %tr
-      %th= t('admin.instances.domain_name')
-      %th= t('admin.instances.account_count')
-  %tbody
-    = render @instances
+= form_tag admin_instances_url, method: 'GET', class: 'simple_form' do
+  .fields-group
+    - %i(domain_name).each do |key|
+      .input.string.optional
+        = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.instances.#{key}")
+
+    .actions
+      %button= t('admin.instances.search')
+      = link_to t('admin.instances.reset'), admin_instances_path, class: 'button negative'
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.instances.domain_name')
+        %th= t('admin.instances.account_count')
+    %tbody
+      = render @instances
 
 = paginate paginated_instances
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
index f1c4a93c4..577c68a86 100644
--- a/app/views/admin/reports/index.html.haml
+++ b/app/views/admin/reports/index.html.haml
@@ -10,17 +10,18 @@
 
 = form_tag do
 
-  %table.table
-    %thead
-      %tr
-        -# %th
-        %th= t('admin.reports.id')
-        %th= t('admin.reports.target')
-        %th= t('admin.reports.reported_by')
-        %th= t('admin.reports.comment.label')
-        %th= t('admin.reports.report_contents')
-        %th
-    %tbody
-      = render @reports
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          -# %th
+          %th= t('admin.reports.id')
+          %th= t('admin.reports.target')
+          %th= t('admin.reports.reported_by')
+          %th= t('admin.reports.comment.label')
+          %th= t('admin.reports.report_contents')
+          %th
+      %tbody
+        = render @reports
 
 = paginate @reports
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 9f8a6640b..468166035 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -11,6 +11,11 @@
   %hr/
 
   .fields-group
+    = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
+
+  %hr/
+
+  .fields-group
     = f.input :timeline_preview, as: :boolean, wrapper: :with_label, label: t('admin.settings.timeline_preview.title'), hint: t('admin.settings.timeline_preview.desc_html')
 
   .fields-group
@@ -28,5 +33,10 @@
     = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 }
     = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 }
 
+  %hr/
+
+  .fields-group
+    = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html')
+
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/subscriptions/index.html.haml b/app/views/admin/subscriptions/index.html.haml
index 21b3238a6..83704c8ee 100644
--- a/app/views/admin/subscriptions/index.html.haml
+++ b/app/views/admin/subscriptions/index.html.haml
@@ -1,15 +1,16 @@
 - content_for :page_title do
   = t('admin.subscriptions.title')
 
-%table.table
-  %thead
-    %tr
-      %th= t('admin.subscriptions.topic')
-      %th= t('admin.subscriptions.callback_url')
-      %th= t('admin.subscriptions.confirmed')
-      %th= t('admin.subscriptions.expires_in')
-      %th= t('admin.subscriptions.last_delivery')
-  %tbody
-    = render @subscriptions
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.subscriptions.topic')
+        %th= t('admin.subscriptions.callback_url')
+        %th= t('admin.subscriptions.confirmed')
+        %th= t('admin.subscriptions.expires_in')
+        %th= t('admin.subscriptions.last_delivery')
+    %tbody
+      = render @subscriptions
 
 = paginate @subscriptions
diff --git a/app/views/auth/registrations/_sessions.html.haml b/app/views/auth/registrations/_sessions.html.haml
index 7ac578bb1..c1e9764b3 100644
--- a/app/views/auth/registrations/_sessions.html.haml
+++ b/app/views/auth/registrations/_sessions.html.haml
@@ -1,28 +1,29 @@
 %h6= t 'sessions.title'
 %p.muted-hint= t 'sessions.explanation'
 
-%table.table.inline-table
-  %thead
-    %tr
-      %th= t 'sessions.browser'
-      %th= t 'sessions.ip'
-      %th= t 'sessions.activity'
-      %td
-  %tbody
-    - @sessions.each do |session|
+.table-wrapper
+  %table.table.inline-table
+    %thead
       %tr
+        %th= t 'sessions.browser'
+        %th= t 'sessions.ip'
+        %th= t 'sessions.activity'
         %td
-          %span{ title: session.user_agent }<
-            = fa_icon "#{session_device_icon(session)} fw", 'aria-label' => session_device_icon(session)
-            = ' '
-            = t 'sessions.description', browser: t("sessions.browsers.#{session.browser}"), platform: t("sessions.platforms.#{session.platform}")
-        %td
-          %samp= session.ip
-        %td
-          - if current_session.session_id == session.session_id
-            = t 'sessions.current_session'
-          - else
-            %time.time-ago{ datetime: session.updated_at.iso8601, title: l(session.updated_at) }= l(session.updated_at)
-        %td
-          - if current_session.session_id != session.session_id
-            = table_link_to 'times', t('sessions.revoke'), settings_session_path(session), method: :delete
+    %tbody
+      - @sessions.each do |session|
+        %tr
+          %td
+            %span{ title: session.user_agent }<
+              = fa_icon "#{session_device_icon(session)} fw", 'aria-label' => session_device_icon(session)
+              = ' '
+              = t 'sessions.description', browser: t("sessions.browsers.#{session.browser}"), platform: t("sessions.platforms.#{session.platform}")
+          %td
+            %samp= session.ip
+          %td
+            - if current_session.session_id == session.session_id
+              = t 'sessions.current_session'
+            - else
+              %time.time-ago{ datetime: session.updated_at.iso8601, title: l(session.updated_at) }= l(session.updated_at)
+          %td
+            - if current_session.session_id != session.session_id
+              = table_link_to 'times', t('sessions.revoke'), settings_session_path(session), method: :delete
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index f016a4883..145f5cd9e 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -1,7 +1,7 @@
 - content_for :page_title do
   = t('auth.change_password')
 
-= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f|
+= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'auth_edit' }) do |f|
   = render 'shared/error_messages', object: resource
 
   = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index d0529a20c..807020310 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -6,11 +6,11 @@
 
   = f.simple_fields_for :account do |ff|
     .input-with-append
-      = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }
+      = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off' }
       .append
         = "@#{site_hostname}"
 
-  = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
+  = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }
   = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
   = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
 
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index cb5e32f3e..2b07c923b 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -2,9 +2,7 @@
   = 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')
+  = 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')
 
   .actions
     = f.button :button, t('auth.login'), type: :submit
diff --git a/app/views/authorize_follows/show.html.haml b/app/views/authorize_follows/show.html.haml
index 3b60df058..f7a8f72d2 100644
--- a/app/views/authorize_follows/show.html.haml
+++ b/app/views/authorize_follows/show.html.haml
@@ -3,10 +3,9 @@
 
 .form-container
   .follow-prompt
-    %h2= t('authorize_follow.prompt_html', self: current_account.username)
-
     = render 'card', account: @account
 
-  = form_tag authorize_follow_path, method: :post, class: 'simple_form' do
-    = hidden_field_tag :acct, @account.acct
-    = button_tag t('authorize_follow.follow'), type: :submit
+  - unless current_account.following?(@account)
+    = form_tag authorize_follow_path, method: :post, class: 'simple_form' do
+      = hidden_field_tag :acct, @account.acct
+      = button_tag t('authorize_follow.follow'), type: :submit
diff --git a/app/views/errors/500.html.haml b/app/views/errors/500.html.haml
new file mode 100644
index 000000000..6244ff209
--- /dev/null
+++ b/app/views/errors/500.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  = t('errors.500.title')
+
+- content_for :content do
+  = t('errors.500.content')
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index ec6e53461..3b4219c56 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -2,12 +2,12 @@
   %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
 
-  = javascript_pack_tag "frontends/#{@frontend}", integrity: true, crossorigin: 'anonymous'
-  = stylesheet_pack_tag "frontends/#{@frontend}", integrity: true, media: 'all'
+  = javascript_pack_tag "themes/#{current_theme}", integrity: true, crossorigin: 'anonymous'
+  = stylesheet_pack_tag "themes/#{current_theme}", integrity: true, media: 'all'
 
 .app-holder#mastodon{ data: { props: Oj.dump(default_props) } }
   %noscript
     = image_tag asset_pack_path('logo.svg'), alt: 'Mastodon'
 
     %div
-      = t('errors.noscript')
+      = t('errors.noscript_html')
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 399d70bc0..e6190f7e2 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -21,13 +21,12 @@
     = stylesheet_pack_tag 'common', media: 'all'
     = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
 
-    = javascript_pack_tag 'features/getting_started', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
-    = javascript_pack_tag 'features/compose', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
-    = javascript_pack_tag 'features/home_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
-    = javascript_pack_tag 'features/notifications', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
-    = javascript_pack_tag 'features/community_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
-    = javascript_pack_tag 'features/public_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
-    = javascript_pack_tag 'emojione_picker', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
+    %link{ href: asset_pack_path('features/getting_started.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
+    %link{ href: asset_pack_path('features/compose.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
+    %link{ href: asset_pack_path('features/home_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
+    %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
+    %link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
+    %link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
 
     = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
     = csrf_meta_tags
@@ -37,7 +36,8 @@
 
     = yield :header_tags
 
-  - body_classes ||= @body_classes
+  - body_classes ||= @body_classes || ''
+  - body_classes += ' system-font' if current_account&.user&.setting_system_font_ui
 
   %body{ class: add_rtl_body_class(body_classes) }
     = content_for?(:content) ? yield(:content) : yield
diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml
index 46dab2d0f..5fc60be17 100644
--- a/app/views/layouts/embedded.html.haml
+++ b/app/views/layouts/embedded.html.haml
@@ -6,6 +6,7 @@
 
     = stylesheet_pack_tag 'common', media: 'all'
     = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
+    = stylesheet_pack_tag 'application', integrity: true, media: 'all'
     = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
     = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
   %body.embed
diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml
index 08b94af54..8b260c619 100644
--- a/app/views/layouts/error.html.haml
+++ b/app/views/layouts/error.html.haml
@@ -3,34 +3,12 @@
   %head
     %meta{ content: 'text/html; charset=UTF-8', 'http-equiv' => 'Content-Type' }/
     %meta{ charset: 'utf-8' }/
-    %title= yield :page_title
+    %title= safe_join([yield(:page_title), title], ' - ')
     %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/
-    %link{ href: 'https://fonts.googleapis.com/css?family=Roboto:400', rel: 'stylesheet' }/
-    :css
-      body {
-        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-        background: #282c37;
-        color: #9baec8;
-        text-align: center;
-        margin: 0;
-        padding: 20px;
-      }
-
-      .dialog img {
-        display: block;
-        margin: 20px auto;
-        margin-top: 50px;
-        max-width: 600px;
-        width: 100%;
-        height: auto;
-      }
-
-      .dialog h1 {
-        font: 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-        font-weight: 400;
-      }
-  %body
+    = stylesheet_pack_tag 'common', media: 'all'
+    = stylesheet_pack_tag 'application', integrity: true, media: 'all'
+  %body.error
     .dialog
-      %img{ alt: 'Mastodon', src: '/oops.png' }/
+      %img{ alt: title, src: '/oops.gif' }/
       %div
         %h1= yield :content
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
new file mode 100644
index 000000000..a819e098d
--- /dev/null
+++ b/app/views/layouts/modal.html.haml
@@ -0,0 +1,16 @@
+- content_for :header_tags do
+  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
+
+- content_for :content do
+  - if user_signed_in?
+    .account-header
+      .avatar= image_tag current_account.avatar.url(:original)
+      .name
+        = t 'users.signed_in_as'
+        %span.username @#{current_account.local_username_and_domain}
+      = link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do
+        = fa_icon 'sign-out'
+
+  .container= yield
+
+= render template: 'layouts/application'
diff --git a/app/views/oauth/authorizations/show.html.haml b/app/views/oauth/authorizations/show.html.haml
index b56667f35..ad5236007 100644
--- a/app/views/oauth/authorizations/show.html.haml
+++ b/app/views/oauth/authorizations/show.html.haml
@@ -1,3 +1,4 @@
 .form-container
   .flash-message
-    %code= params[:code]
+    %p= t('doorkeeper.authorizations.show.title')
+    %input{ type: 'text', class: 'oauth-code', readonly: true, value: params[:code], onClick: 'select()' }
diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml
index ba0c08495..19af5f55d 100644
--- a/app/views/oauth/authorized_applications/index.html.haml
+++ b/app/views/oauth/authorized_applications/index.html.haml
@@ -1,23 +1,24 @@
 - content_for :page_title do
   = t('doorkeeper.authorized_applications.index.title')
 
-%table.table
-  %thead
-    %tr
-      %th= t('doorkeeper.authorized_applications.index.application')
-      %th= t('doorkeeper.authorized_applications.index.scopes')
-      %th= t('doorkeeper.authorized_applications.index.created_at')
-      %th
-  %tbody
-    - @applications.each do |application|
+.table-wrapper
+  %table.table
+    %thead
       %tr
-        %td
-          - if application.website.blank?
-            = application.name
-          - else
-            = link_to application.name, application.website, target: '_blank', rel: 'noopener'
-        %th!= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join('<br />')
-        %td= l application.created_at
-        %td
-          - unless application.superapp?
-            = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') }
+        %th= t('doorkeeper.authorized_applications.index.application')
+        %th= t('doorkeeper.authorized_applications.index.scopes')
+        %th= t('doorkeeper.authorized_applications.index.created_at')
+        %th
+    %tbody
+      - @applications.each do |application|
+        %tr
+          %td
+            - if application.website.blank?
+              = application.name
+            - else
+              = link_to application.name, application.website, target: '_blank', rel: 'noopener'
+          %th!= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join('<br />')
+          %td= l application.created_at
+          %td
+            - unless application.superapp?
+              = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') }
diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml
new file mode 100644
index 000000000..b21f3cca6
--- /dev/null
+++ b/app/views/settings/applications/_fields.html.haml
@@ -0,0 +1,21 @@
+.fields-group
+  = f.input :name, placeholder: t('activerecord.attributes.doorkeeper/application.name')
+  = f.input :website, placeholder: t('activerecord.attributes.doorkeeper/application.website')
+
+.fields-group
+  = f.input :redirect_uri, wrapper: :with_block_label, label: t('activerecord.attributes.doorkeeper/application.redirect_uri'), hint: t('doorkeeper.applications.help.redirect_uri')
+
+  %p.hint= t('doorkeeper.applications.help.native_redirect_uri', native_redirect_uri: Doorkeeper.configuration.native_redirect_uri)
+
+.field-group
+  = f.input :scopes,
+    label: t('activerecord.attributes.doorkeeper/application.scopes'),
+    collection: Doorkeeper.configuration.scopes,
+    wrapper: :with_label,
+    include_blank: false,
+    label_method: lambda { |scope| safe_join([scope, content_tag(:span, t("doorkeeper.scopes.#{scope}"), class: 'hint')]) },
+    selected: f.object.scopes.all,
+    required: false,
+    as: :check_boxes,
+    collection_wrapper_tag: 'ul',
+    item_wrapper_tag: 'li'
diff --git a/app/views/settings/applications/index.html.haml b/app/views/settings/applications/index.html.haml
new file mode 100644
index 000000000..919472c2e
--- /dev/null
+++ b/app/views/settings/applications/index.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+  = t('doorkeeper.applications.index.title')
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('doorkeeper.applications.index.application')
+        %th= t('doorkeeper.applications.index.scopes')
+        %th
+    %tbody
+      - @applications.each do |application|
+        %tr
+          %td= link_to application.name, settings_application_path(application)
+          %th= application.scopes
+          %td
+            = table_link_to 'times', t('doorkeeper.applications.index.delete'), settings_application_path(application), method: :delete, data: { confirm: t('doorkeeper.applications.confirmations.destroy') }
+
+= paginate @applications
+= link_to t('doorkeeper.applications.index.new'), new_settings_application_path, class: 'button'
diff --git a/app/views/settings/applications/new.html.haml b/app/views/settings/applications/new.html.haml
new file mode 100644
index 000000000..5274a430c
--- /dev/null
+++ b/app/views/settings/applications/new.html.haml
@@ -0,0 +1,8 @@
+- content_for :page_title do
+  = t('doorkeeper.applications.new.title')
+
+= simple_form_for @application, url: settings_applications_path do |f|
+  = render 'fields', f: f
+  
+  .actions
+    = f.button :button, t('doorkeeper.applications.buttons.submit'), type: :submit
diff --git a/app/views/settings/applications/show.html.haml b/app/views/settings/applications/show.html.haml
new file mode 100644
index 000000000..12baed088
--- /dev/null
+++ b/app/views/settings/applications/show.html.haml
@@ -0,0 +1,31 @@
+- content_for :page_title do
+  = t('doorkeeper.applications.show.title', name: @application.name)
+
+%p.hint= t('applications.warning')
+
+.table-wrapper
+  %table.table
+    %tbody
+      %tr  
+        %th= t('doorkeeper.applications.show.application_id')
+        %td
+          %code= @application.uid
+      %tr
+        %th= t('doorkeeper.applications.show.secret')
+        %td
+          %code= @application.secret
+      %tr
+        %th{ rowspan: 2}= t('applications.your_token')
+        %td
+          %code= current_user.token_for_app(@application).token
+      %tr
+        %td= table_link_to 'refresh', t('applications.regenerate_token'), regenerate_settings_application_path(@application), method: :post
+
+%hr/
+
+= simple_form_for @application, url: settings_application_path(@application), method: :put do |f|
+  = render 'fields', f: f
+    
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
+
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index f2f6f9556..e0df1c480 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -1,21 +1,22 @@
 - content_for :page_title do
   = t('settings.export')
 
-%table.table
-  %tbody
-    %tr
-      %th= t('exports.storage')
-      %td= number_to_human_size @export.total_storage
-      %td
-    %tr
-      %th= t('exports.follows')
-      %td= @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= table_link_to 'download', t('exports.csv'), settings_exports_blocks_path(format: :csv)
-    %tr
-      %th= t('exports.mutes')
-      %td= @export.total_mutes
-      %td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
+.table-wrapper
+  %table.table
+    %tbody
+      %tr
+        %th= t('exports.storage')
+        %td= number_to_human_size @export.total_storage
+        %td
+      %tr
+        %th= t('exports.follows')
+        %td= @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= table_link_to 'download', t('exports.csv'), settings_exports_blocks_path(format: :csv)
+      %tr
+        %th= t('exports.mutes')
+        %td= @export.total_mutes
+        %td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
diff --git a/app/views/settings/follower_domains/show.html.haml b/app/views/settings/follower_domains/show.html.haml
index dad2770f1..f1687d4d2 100644
--- a/app/views/settings/follower_domains/show.html.haml
+++ b/app/views/settings/follower_domains/show.html.haml
@@ -12,20 +12,21 @@
   %p= t('followers.explanation_html')
   %p= t('followers.true_privacy_html')
 
-  %table.table
-    %thead
-      %tr
-        %th
-        %th= t('followers.domain')
-        %th= t('followers.followers_count')
-    %tbody
-      - @domains.each do |domain|
+  .table-wrapper
+    %table.table
+      %thead
         %tr
-          %td
-            = check_box_tag 'select[]', domain.domain, false, disabled: !@account.locked? unless domain.domain.nil?
-          %td
-            %samp= domain.domain.presence || Rails.configuration.x.local_domain
-          %td= number_with_delimiter domain.accounts_from_domain
+          %th
+          %th= t('followers.domain')
+          %th= t('followers.followers_count')
+      %tbody
+        - @domains.each do |domain|
+          %tr
+            %td
+              = check_box_tag 'select[]', domain.domain, false, disabled: !@account.locked? unless domain.domain.nil?
+            %td
+              %samp= domain.domain.presence || Rails.configuration.x.local_domain
+            %td= number_with_delimiter domain.accounts_from_domain
 
   .action-pagination
     .actions
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index fae6090c8..5efd538e4 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -5,6 +5,8 @@
   = render 'shared/error_messages', object: current_user
 
   .fields-group
+    = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| safe_join([I18n.t("themes.#{theme}", default: theme)])}, wrapper: :with_label, include_blank: false
+
     = f.input :locale,
       collection: I18n.available_locales,
       wrapper: :with_label,
@@ -13,7 +15,7 @@
       selected: I18n.locale
 
     = f.input :filtered_languages,
-      collection: I18n.available_locales,
+      collection: filterable_languages,
       wrapper: :with_block_label,
       include_blank: false,
       label_method: lambda { |locale| human_locale(locale) },
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 3fa540bba..551a7ca49 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -8,8 +8,8 @@
     = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe
     = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 500 - @account.note.size).html_safe
 
-  .card.compact{ style: "background-image: url(#{@account.header.url(:original)})" }
-    .avatar= image_tag @account.avatar.url(:original)
+  .card.compact{ style: "background-image: url(#{@account.header.url(:original)})", data: { original_src: @account.header.url(:original) } }
+    .avatar= image_tag @account.avatar.url(:original), data: { original_src: @account.avatar.url(:original) }
 
   .fields-group
     = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar')
diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml
index 35461a8cb..ae26fc1ff 100644
--- a/app/views/shared/_landing_strip.html.haml
+++ b/app/views/shared/_landing_strip.html.haml
@@ -1,5 +1,8 @@
 .landing-strip
-  = 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))
+  = image_tag asset_pack_path('logo.svg'), class: 'logo'
 
-  - if open_registrations?
-    = t('landing_strip_signup_html', sign_up_path: new_user_registration_path)
+  %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))
+
+    - if open_registrations?
+      = t('landing_strip_signup_html', sign_up_path: new_user_registration_path)
diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml
new file mode 100644
index 000000000..44b6f145f
--- /dev/null
+++ b/app/views/shares/show.html.haml
@@ -0,0 +1,5 @@
+- content_for :header_tags do
+  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
+  = javascript_pack_tag 'share', integrity: true, crossorigin: 'anonymous'
+
+#mastodon-compose{ data: { props: Oj.dump(default_props) } }
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 157a7e7fb..9a26d2c0b 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -7,25 +7,27 @@
       %strong.p-name.emojify= display_name(status.account)
       %span= acct(status.account)
 
+  - if embedded_view?
+    = link_to "web+mastodon://follow?uri=#{status.account.local_username_and_domain}", class: 'button button-secondary logo-button', target: '_new' do
+      = render file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')
+      = t('accounts.follow')
+
   .status__content.p-name.emojify<
     - if status.spoiler_text?
       %p{ style: 'margin-bottom: 0' }<
-        %span.p-summary> #{status.spoiler_text}&nbsp;
+        %span.p-summary> #{Formatter.instance.format_spoiler(status)}&nbsp;
         %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
-    .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
-      = Formatter.instance.format(status)
+    .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }
+      = Formatter.instance.format(status, custom_emojify: true)
 
-      - unless status.media_attachments.empty?
+      - if !status.media_attachments.empty?
         - if status.media_attachments.first.video?
-          .video-player><
-            = render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? }
-            %video.u-video{ src: status.media_attachments.first.file.url(:original), loop: true }
+          - video = status.media_attachments.first
+          %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }}><
         - else
-          .detailed-status__attachments><
-            = render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? }
-            .status__attachments__inner<
-              - status.media_attachments.each do |media|
-                = render partial: 'stream_entries/media', locals: { media: media }
+          %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}><
+      - elsif status.preview_cards.first
+        %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}><
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/stream_entries/_og_description.html.haml b/app/views/stream_entries/_og_description.html.haml
index 5762aca04..d2fa99e63 100644
--- a/app/views/stream_entries/_og_description.html.haml
+++ b/app/views/stream_entries/_og_description.html.haml
@@ -1,4 +1,4 @@
 - if activity.is_a?(Status) && activity.spoiler_text?
-  %meta{ property: 'og:description', content: activity.spoiler_text }/
+  = opengraph 'og:description', activity.spoiler_text
 - else
-  %meta{ property: 'og:description', content: activity.content }/
+  = opengraph 'og:description', activity.content
diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/stream_entries/_og_image.html.haml
index f725209d8..b5058583b 100644
--- a/app/views/stream_entries/_og_image.html.haml
+++ b/app/views/stream_entries/_og_image.html.haml
@@ -1,6 +1,23 @@
 - if activity.is_a?(Status) && activity.non_sensitive_with_media?
-  %meta{ property: 'og:image', content: full_asset_url(activity.media_attachments.first.file.url(:small)) }/
+  - activity.media_attachments.each do |media|
+    - if media.image?
+      = opengraph 'og:image', full_asset_url(media.file.url(:original))
+      = opengraph 'og:image:type', media.file_content_type
+      - unless media.file.meta.nil?
+        = opengraph 'og:image:width', media.file.meta['original']['width']
+        = opengraph 'og:image:height', media.file.meta['original']['height']
+    - elsif media.video?
+      = opengraph 'og:image', full_asset_url(media.file.url(:small))
+      = opengraph 'og:image:type', 'image/png'
+      - unless media.file.meta.nil?
+        = opengraph 'og:image:width', media.file.meta['small']['width']
+        = opengraph 'og:image:height', media.file.meta['small']['height']
+      = opengraph 'og:video', full_asset_url(media.file.url(:original))
+      = opengraph 'og:video:type', media.file_content_type
+      - unless media.file.meta.nil?
+        = opengraph 'og:video:width', media.file.meta['small']['width']
+        = opengraph 'og:video:height', media.file.meta['small']['height']
 - else
-  %meta{ property: 'og:image', content: full_asset_url(account.avatar.url(:original)) }/
-  %meta{ property: 'og:image:width', content: '120' }/
-  %meta{ property: 'og:image:height', content: '120' }/
+  = opengraph 'og:image', full_asset_url(account.avatar.url(:original))
+  = opengraph 'og:image:width', '120'
+  = opengraph 'og:image:height','120'
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index b44f9820f..9a2524219 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -16,21 +16,14 @@
   .status__content.p-name.emojify<
     - if status.spoiler_text?
       %p{ style: 'margin-bottom: 0' }<
-        %span.p-summary> #{status.spoiler_text}&nbsp;
+        %span.p-summary> #{Formatter.instance.format_spoiler(status)}&nbsp;
         %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
-    .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
-      = Formatter.instance.format(status)
+    .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }
+      = Formatter.instance.format(status, custom_emojify: true)
 
       - unless status.media_attachments.empty?
-        .status__attachments><
-          = render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? }
-          - if status.media_attachments.first.video?
-            .status__attachments__inner<
-              .video-item<
-                = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
-                  .video-item__play
-                    = fa_icon('play')
-          - else
-            .status__attachments__inner<
-              - status.media_attachments.each do |media|
-                = render partial: 'stream_entries/media', locals: { media: media }
+        - if status.media_attachments.first.video?
+          - video = status.media_attachments.first
+          %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 610, height: 343) }}><
+        - else
+          %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}><
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index 50a373743..e2e1fdd12 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -1,4 +1,5 @@
 :ruby
+  pinned          ||= false
   include_threads ||= false
   is_predecessor  ||= false
   is_successor    ||= false
@@ -25,6 +26,12 @@
         = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do
           %strong.emojify= display_name(status.account)
         = t('stream_entries.reblogged')
+  - elsif pinned
+    .pre-header
+      .pre-header__icon
+        = fa_icon('thumb-tack fw')
+      %span
+        = t('stream_entries.pinned')
 
   = render (centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status'), status: status.proper
 
diff --git a/app/views/stream_entries/embed.html.haml b/app/views/stream_entries/embed.html.haml
index 5df82528b..b703c15d2 100644
--- a/app/views/stream_entries/embed.html.haml
+++ b/app/views/stream_entries/embed.html.haml
@@ -1,2 +1,3 @@
-.activity-stream.activity-stream-headless
-  = render @type, @type.to_sym => @stream_entry.activity, centered: true
+- cache @stream_entry.activity do
+  .activity-stream.activity-stream-headless
+    = render "stream_entries/#{@type}", @type.to_sym => @stream_entry.activity, centered: true
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 80ea30eb1..1bb8a32b2 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -4,16 +4,17 @@
 
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/
   %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/
+  %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@stream_entry.activity) }/
 
-  %meta{ property: 'og:site_name', content: site_title }/
-  %meta{ property: 'og:type', content: 'article' }/
-  %meta{ property: 'og:title', content: "#{@account.username} on #{site_hostname}" }/
-  %meta{ property: 'og:url', content: account_stream_entry_url(@account, @stream_entry) }/
+  = opengraph 'og:site_name', site_title
+  = opengraph 'og:type', 'article'
+  = opengraph 'og:title', "#{@account.username} on #{site_hostname}"
+  = opengraph 'og:url', account_stream_entry_url(@account, @stream_entry)
 
   = render 'stream_entries/og_description', activity: @stream_entry.activity
   = render 'stream_entries/og_image', activity: @stream_entry.activity, account: @account
 
-  %meta{ property: 'twitter:card', content: 'summary' }/
+  = opengraph 'twitter:card', 'summary_large_image'
 
 - if show_landing_strip?
   = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account }
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 15bf714c2..8cd2f1825 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -4,6 +4,7 @@
 .compact-header
   %h1<
     = link_to site_title, root_path
+    %br
     %small ##{@tag.name}
 
 - if @statuses.empty?
diff --git a/app/views/user_mailer/confirmation_instructions.es.html.erb b/app/views/user_mailer/confirmation_instructions.es.html.erb
new file mode 100644
index 000000000..1d46a12c0
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.es.html.erb
@@ -0,0 +1,12 @@
+<p>¡Bienvenido, <%= @resource.email %>!</p>
+
+<p>Acabas de crear una cuenta en <%= @instance %>.</p>
+
+<p>Para confirmar tu registro, por favor ingresa al siguiente enlace:<br>
+<%= link_to 'Confirmar mi cuenta', confirmation_url(@resource, confirmation_token: @token) %>
+
+<p>También revisa nuestros <%= link_to 'términos y condiciones', terms_url %>.</p>
+
+<p>Sinceramente,<p>
+
+<p>El equipo de <%= @instance %></p>
\ No newline at end of file
diff --git a/app/views/user_mailer/confirmation_instructions.es.text.erb b/app/views/user_mailer/confirmation_instructions.es.text.erb
new file mode 100644
index 000000000..e9d83b3f8
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.es.text.erb
@@ -0,0 +1,12 @@
+¡Bienvenido, <%= @resource.email %>!
+
+Acabas de crear una cuenta en <%= @instance %>.
+
+Para confirmar tu registro, por favor ingresa al siguiente enlace:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+Por favor, también revisa nuestros términos y condiciones <%= terms_url %>
+
+Sinceramente,
+
+El equipo de <%= @instance %>
\ No newline at end of file
diff --git a/app/views/user_mailer/confirmation_instructions.oc.html.erb b/app/views/user_mailer/confirmation_instructions.oc.html.erb
index 9e9732bd8..7a16db67a 100644
--- a/app/views/user_mailer/confirmation_instructions.oc.html.erb
+++ b/app/views/user_mailer/confirmation_instructions.oc.html.erb
@@ -1,13 +1,13 @@
-<p>Bonjorn <%= @resource.email %>&nbsp;!<p>
+<p>Bonjorn <%= @resource.email %> !<p>
 
 <p>Venètz de vos crear un compte sus <%= @instance %> e vos mercegem :)</p>
 
-<p>Per confirmar vòstre inscripcion, mercés de clicar sul ligam seguent : <br>
+<p>Per confirmar vòstra inscripcion, mercés de clicar sul ligam seguent : <br>
 <%= link_to 'Confirmar mon compte', confirmation_url(@resource, confirmation_token: @token) %></p>
 
 <p>Aprèp vòstra primièra connexion, poiretz accedir a la documentacion de l’aisina.</p>
 
-<p>Pensatz tanben a gaitar nòstras <%= link_to 'conditions d\'utilisation', terms_url %>.</p>
+<p>Pensatz tanben de gaitar nòstras <%= link_to 'conditions d\'utilisation', terms_url %>.</p>
 
 <p>Amistosament,</p>
 
diff --git a/app/views/user_mailer/confirmation_instructions.oc.text.erb b/app/views/user_mailer/confirmation_instructions.oc.text.erb
index ff1abf62d..bf2acfec1 100644
--- a/app/views/user_mailer/confirmation_instructions.oc.text.erb
+++ b/app/views/user_mailer/confirmation_instructions.oc.text.erb
@@ -1,13 +1,13 @@
-Bonjorn <%= @resource.email %>&nbsp;!
+Bonjorn <%= @resource.email %> !
 
 Venètz de vos crear un compte sus <%= @instance %> e vos mercegem :)
 
-er confirmar vòstre inscripcion, mercés de clicar sul ligam seguent :
+er confirmar vòstra inscripcion, mercés de clicar sul ligam seguent : 
 <%= link_to 'Confirmar mon compte', confirmation_url(@resource, confirmation_token: @token) %>
 
 Aprèp vòstra primièra connexion, poiretz accedir a la documentacion de l’aisina.
 
-Pensatz tanben a gaitar nòstras <%= link_to 'conditions d\'utilisation', terms_url %>.
+Pensatz tanben de gaitar nòstras <%= link_to 'conditions d\'utilisation', terms_url %>.
 
 Amistosament,
 
diff --git a/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb b/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb
new file mode 100644
index 000000000..80edcfda7
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.pt-BR.html.erb
@@ -0,0 +1,12 @@
+<p>Boas vindas, <%= @resource.email %>!</p>
+
+<p>Você acabou de criar uma conta no <%= @instance %>.</p>
+
+<p>Para confirmar o seu cadastro, por favor clique no link a seguir: <br>
+<%= link_to 'Confirmar cadastro', confirmation_url(@resource, confirmation_token: @token) %>
+
+<p>Por favor, leia também os nossos <%= link_to 'termos de serviços', terms_url %>.</p>
+
+<p>Atenciosamente,<p>
+
+<p>A equipe do <%= @instance %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.pt-BR.text.erb b/app/views/user_mailer/confirmation_instructions.pt-BR.text.erb
new file mode 100644
index 000000000..95efb3436
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.pt-BR.text.erb
@@ -0,0 +1,12 @@
+Boas vindas, <%= @resource.email %>!
+
+Você acabou de criar uma conta no <%= @instance %>.
+
+Para confirmar o seu cadastro, por favor clique no link a seguir:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+Por favor, leia também os nossos termos e condições de uso <%= terms_url %>
+
+Atenciosamente,
+
+A equipe do <%= @instance %>
diff --git a/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb b/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb
index 575b2ff9e..de2f8b6e0 100644
--- a/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb
+++ b/app/views/user_mailer/confirmation_instructions.zh-cn.html.erb
@@ -3,7 +3,7 @@
 <p>你刚刚在 <%= @instance %> 创建了帐号。</p>
 
 <p>点击下面的链接来完成注册啦 : <br>
-<%= link_to '确认账户', confirmation_url(@resource, confirmation_token: @token) %>
+<%= link_to '确认帐户', confirmation_url(@resource, confirmation_token: @token) %>
 
 <p>别忘了看看 <%= link_to '使用条款', terms_url %>。</p>
 
diff --git a/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb b/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb
index ce237a32d..d7d4b4b23 100644
--- a/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb
+++ b/app/views/user_mailer/confirmation_instructions.zh-cn.text.erb
@@ -3,7 +3,7 @@
 你刚刚在 <%= @instance %> 创建了帐号。
 
 点击下面的链接来完成注册啦 : <br>
-<%= link_to '确认账户', confirmation_url(@resource, confirmation_token: @token) %>
+<%= link_to '确认帐户', confirmation_url(@resource, confirmation_token: @token) %>
 
 别忘了看看 <%= link_to 'terms and conditions', terms_url %>。
 
diff --git a/app/views/user_mailer/password_change.es.html.erb b/app/views/user_mailer/password_change.es.html.erb
new file mode 100644
index 000000000..0a9eb4c4c
--- /dev/null
+++ b/app/views/user_mailer/password_change.es.html.erb
@@ -0,0 +1,3 @@
+<p>¡Hola, <%= @resource.email %>!</p>
+
+<p>Te contactamos para notificarte que tu contraseña en <%= @instance %> ha sido modificada.</p>
\ No newline at end of file
diff --git a/app/views/user_mailer/password_change.es.text.erb b/app/views/user_mailer/password_change.es.text.erb
new file mode 100644
index 000000000..192faf9ad
--- /dev/null
+++ b/app/views/user_mailer/password_change.es.text.erb
@@ -0,0 +1,3 @@
+¡Hola, <%= @resource.email %>!
+
+Te contactamos para notificarte que tu contraseña en <%= @instance %> ha sido modificada.
\ No newline at end of file
diff --git a/app/views/user_mailer/password_change.oc.html.erb b/app/views/user_mailer/password_change.oc.html.erb
index a6b506f5e..094c221a8 100644
--- a/app/views/user_mailer/password_change.oc.html.erb
+++ b/app/views/user_mailer/password_change.oc.html.erb
@@ -1,3 +1,3 @@
-<p>Bonjorn <%= @resource.email %>&nbsp;!</p>
+<p>Bonjorn <%= @resource.email %> !</p>
 
-<p>Vos contactem per vos avisar que vòstre senhal per Mastodon es ben estat cambiat.</p>
+<p>Vos contactem per vos avisar qu’avèm ben cambiat vòstre senhal Mastodon.</p>
diff --git a/app/views/user_mailer/password_change.oc.text.erb b/app/views/user_mailer/password_change.oc.text.erb
index 1caebde69..9fe9116d9 100644
--- a/app/views/user_mailer/password_change.oc.text.erb
+++ b/app/views/user_mailer/password_change.oc.text.erb
@@ -1,3 +1,3 @@
-Bonjorn <%= @resource.email %>&nbsp;!
+Bonjorn <%= @resource.email %> !
 
-Vos contactem per vos avisar que vòstre senhal per Mastodon es ben estat cambiat.
+Vos contactem per vos avisar qu’avèm ben cambiat vòstre senhal Mastodon.
diff --git a/app/views/user_mailer/password_change.pt-BR.html.erb b/app/views/user_mailer/password_change.pt-BR.html.erb
new file mode 100644
index 000000000..5f707ba09
--- /dev/null
+++ b/app/views/user_mailer/password_change.pt-BR.html.erb
@@ -0,0 +1,3 @@
+<p>Olá, <%= @resource.email %>!</p>
+
+<p>Estamos te contatando para te notificar que a senha senha no <%= @instance %> foi modificada.</p>
diff --git a/app/views/user_mailer/password_change.pt-BR.text.erb b/app/views/user_mailer/password_change.pt-BR.text.erb
new file mode 100644
index 000000000..d8b76648c
--- /dev/null
+++ b/app/views/user_mailer/password_change.pt-BR.text.erb
@@ -0,0 +1,3 @@
+Olá, <%= @resource.email %>!
+
+Estamos te contatando para te notificar que a senha senha no <%= @instance %> foi modificada.
diff --git a/app/views/user_mailer/reset_password_instructions.es.html.erb b/app/views/user_mailer/reset_password_instructions.es.html.erb
new file mode 100644
index 000000000..4eeb6601d
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.es.html.erb
@@ -0,0 +1,8 @@
+<p>¡Hola, <%= @resource.email %>!</p>
+
+<p>Alguien pidió un enlace para cambiar tu contraseña en <%= @instance %>. Puedes hacer esto con el siguiente enlace.</p>
+
+<p><%= link_to 'Cambiar mi contraseña', edit_password_url(@resource, reset_password_token: @token) %></p>
+
+<p>Si no fuiste tú, por favor ignora este mensaje.</p>
+<p>Tu contraseña no cambiará hasta que ingreses al enlace y crees una nueva.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.es.text.erb b/app/views/user_mailer/reset_password_instructions.es.text.erb
new file mode 100644
index 000000000..8abafcc99
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.es.text.erb
@@ -0,0 +1,8 @@
+¡Hola, <%= @resource.email %>!
+
+Alguien pidió un enlace para cambiar tu contraseña en <%= @instance %>. Puedes hacer esto con el siguiente enlace.
+
+<%= edit_password_url(@resource, reset_password_token: @token) %>
+
+Si no fuiste tú, por favor ignora este mensaje.
+Tu contraseña no cambiará hasta que ingreses al enlace y crees una nueva.
diff --git a/app/views/user_mailer/reset_password_instructions.oc.html.erb b/app/views/user_mailer/reset_password_instructions.oc.html.erb
index acf2c0abd..6c775b3a1 100644
--- a/app/views/user_mailer/reset_password_instructions.oc.html.erb
+++ b/app/views/user_mailer/reset_password_instructions.oc.html.erb
@@ -1,8 +1,8 @@
-<p>Bonjorn <%= @resource.email %>&nbsp;!</p>
+<p>Bonjorn <%= @resource.email %> !</p>
 
-<p>Qualqu’un a demandat una reĩnicializacion de vòstre senhal per Mastodon. Podètz realizar la reĩnicializacion en clicant sul ligam çai-jos.</p>
+<p>Qualqu’un a demandat la reĩnicializacion de vòstre senhal per Mastodon. Podètz realizar la reĩnicializacion en clicant sul ligam çai-jos.</p>
 
 <p><%= link_to 'Modificar mon senhal', edit_password_url(@resource, reset_password_token: @token) %></p>
 
 <p>S’avètz pas res demandat, fasquètz pas cas a aqueste corrièl.</p>
-<p>Vòstre senhal cambiarà pas se clicatz pas sul ligam e que ne causissètz pas un nòu.</p>
+<p>Vòstre senhal cambiarà pas se clicatz pas sul ligam e que ne causissètz pas un novèl.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.oc.text.erb b/app/views/user_mailer/reset_password_instructions.oc.text.erb
index 211974cc2..26432d2df 100644
--- a/app/views/user_mailer/reset_password_instructions.oc.text.erb
+++ b/app/views/user_mailer/reset_password_instructions.oc.text.erb
@@ -1,8 +1,8 @@
-Bonjorn <%= @resource.email %>&nbsp;!
+Bonjorn <%= @resource.email %> !
 
-Qualqu’un a demandat una reĩnicializacion de vòstre senhal per Mastodon. Podètz realizar la reĩnicializacion en clicant sul ligam çai-jos.</p>
+Qualqu’un a demandat la reĩnicializacion de vòstre senhal per Mastodon. Podètz realizar la reĩnicializacion en clicant sul ligam çai-jos.</p>
 
 <%= link_to 'Modificar mon senhal', edit_password_url(@resource, reset_password_token: @token) %>
 
 S’avètz pas res demandat, fasquètz pas cas a aqueste corrièl.
-Vòstre senhal cambiarà pas se clicatz pas sul ligam e que ne causissètz pas un nòu.
+Vòstre senhal cambiarà pas se clicatz pas sul ligam e que ne causissètz pas un novèl.
diff --git a/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb b/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb
new file mode 100644
index 000000000..940438b7c
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.pt-BR.html.erb
@@ -0,0 +1,8 @@
+<p>Olá, <%= @resource.email %>!</p>
+
+<p>Alguém solicitou um link para mudar a sua senha no <%= @instance %>. Você pode fazer isso através do link abaixo:</p>
+
+<p><%= link_to 'Mudar a minha senha', edit_password_url(@resource, reset_password_token: @token) %></p>
+
+<p>Se você não solicitou isso, por favor ignore este e-mail.</p>
+<p>A senha senha não será modificada até que você acesse o link acima e crie uma nova.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.pt-BR.text.erb b/app/views/user_mailer/reset_password_instructions.pt-BR.text.erb
new file mode 100644
index 000000000..f574fe08f
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.pt-BR.text.erb
@@ -0,0 +1,8 @@
+Olá, <%= @resource.email %>!
+
+Alguém solicitou um link para mudar a sua senha no <%= @instance %>. Você pode fazer isso através do link abaixo:
+
+<%= edit_password_url(@resource, reset_password_token: @token) %>
+
+Se você não solicitou isso, por favor ignore este e-mail.
+A senha senha não será modificada até que você acesse o link acima e crie uma nova.
diff --git a/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb b/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb
index 245382b2c..51e3073f1 100644
--- a/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb
+++ b/app/views/user_mailer/reset_password_instructions.zh-cn.html.erb
@@ -1,6 +1,6 @@
 <p><%= @resource.email %> ,嗨呀!!</p>
 
-<p>有人(但愿是你)请求更改你Mastodon账户的密码。如果是你的话,请点击下面的链接:</p>
+<p>有人(但愿是你)请求更改你Mastodon帐户的密码。如果是你的话,请点击下面的链接:</p>
 
 <p><%= link_to '更改密码', edit_password_url(@resource, reset_password_token: @token) %></p>
 
diff --git a/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb b/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb
index 574a0bb2e..7df590f78 100644
--- a/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb
+++ b/app/views/user_mailer/reset_password_instructions.zh-cn.text.erb
@@ -1,6 +1,6 @@
 <%= @resource.email %> ,嗨呀!!
 
-有人(但愿是你)请求更改你Mastodon账户的密码。如果是你的话,请点击下面的链接:
+有人(但愿是你)请求更改你Mastodon帐户的密码。如果是你的话,请点击下面的链接:
 
 <%= link_to '更改密码', edit_password_url(@resource, reset_password_token: @token) %>
 
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
new file mode 100644
index 000000000..a4e829343
--- /dev/null
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class ActivityPub::DeliveryWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push', retry: 5, dead: false
+
+  HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
+
+  def perform(json, source_account_id, inbox_url)
+    @json           = json
+    @source_account = Account.find(source_account_id)
+    @inbox_url      = inbox_url
+
+    perform_request
+
+    raise Mastodon::UnexpectedResponseError, @response unless response_successful?
+  rescue => e
+    raise e.class, "Delivery failed for #{inbox_url}: #{e.message}", e.backtrace[0]
+  end
+
+  private
+
+  def build_request
+    request = Request.new(:post, @inbox_url, body: @json)
+    request.on_behalf_of(@source_account, :uri)
+    request.add_headers(HEADERS)
+  end
+
+  def perform_request
+    @response = build_request.perform
+  end
+
+  def response_successful?
+    @response.code > 199 && @response.code < 300
+  end
+end
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
new file mode 100644
index 000000000..14bb933c0
--- /dev/null
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class ActivityPub::DistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(status_id)
+    @status  = Status.find(status_id)
+    @account = @status.account
+
+    return if skip_distribution?
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [signed_payload, @account.id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def skip_distribution?
+    @status.direct_visibility?
+  end
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+
+  def signed_payload
+    @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
+  end
+
+  def payload
+    @payload ||= ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+end
diff --git a/app/workers/activitypub/post_upgrade_worker.rb b/app/workers/activitypub/post_upgrade_worker.rb
new file mode 100644
index 000000000..4154b8582
--- /dev/null
+++ b/app/workers/activitypub/post_upgrade_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ActivityPub::PostUpgradeWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(domain)
+    Account.where(domain: domain)
+           .where(protocol: :ostatus)
+           .where.not(last_webfingered_at: nil)
+           .in_batches
+           .update_all(last_webfingered_at: nil)
+  end
+end
diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb
new file mode 100644
index 000000000..bb9adf64b
--- /dev/null
+++ b/app/workers/activitypub/processing_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessingWorker
+  include Sidekiq::Worker
+
+  sidekiq_options backtrace: true
+
+  def perform(account_id, body)
+    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id))
+  end
+end
diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb
new file mode 100644
index 000000000..d73466f6e
--- /dev/null
+++ b/app/workers/activitypub/raw_distribution_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ActivityPub::RawDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(json, source_account_id)
+    @account = Account.find(source_account_id)
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [json, @account.id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+end
diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
new file mode 100644
index 000000000..fe99fc05f
--- /dev/null
+++ b/app/workers/activitypub/reply_distribution_worker.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class ActivityPub::ReplyDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(status_id)
+    @status  = Status.find(status_id)
+    @account = @status.thread&.account
+
+    return if @account.nil? || skip_distribution?
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [signed_payload, @status.account_id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def skip_distribution?
+    @status.private_visibility? || @status.direct_visibility?
+  end
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+
+  def signed_payload
+    @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@status.account))
+  end
+
+  def payload
+    @payload ||= ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+end
diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb
new file mode 100644
index 000000000..f3377dcec
--- /dev/null
+++ b/app/workers/activitypub/update_distribution_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class ActivityPub::UpdateDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(account_id)
+    @account = Account.find(account_id)
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [payload, @account.id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+
+  def payload
+    @payload ||= ActiveModelSerializers::SerializableResource.new(
+      @account,
+      serializer: ActivityPub::UpdateSerializer,
+      adapter: ActivityPub::Adapter
+    ).to_json
+  end
+end
diff --git a/app/workers/authorize_follow_worker.rb b/app/workers/authorize_follow_worker.rb
new file mode 100644
index 000000000..0d5014624
--- /dev/null
+++ b/app/workers/authorize_follow_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class AuthorizeFollowWorker
+  include Sidekiq::Worker
+
+  def perform(source_account_id, target_account_id)
+    source_account = Account.find(source_account_id)
+    target_account = Account.find(target_account_id)
+
+    AuthorizeFollowService.new.call(source_account, target_account)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/bootstrap_timeline_worker.rb b/app/workers/bootstrap_timeline_worker.rb
new file mode 100644
index 000000000..89cfb4c3a
--- /dev/null
+++ b/app/workers/bootstrap_timeline_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class BootstrapTimelineWorker
+  include Sidekiq::Worker
+
+  def perform(account_id)
+    BootstrapTimelineService.new.call(Account.find(account_id))
+  end
+end
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 88645cf33..110b8bf16 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -17,7 +17,7 @@ class Pubsubhubbub::DeliveryWorker
     @payload = payload
     process_delivery unless blocked_domain?
   rescue => e
-    raise e.class, "Delivery failed for #{subscription&.callback_url}: #{e.message}"
+    raise e.class, "Delivery failed for #{subscription&.callback_url}: #{e.message}", e.backtrace[0]
   end
 
   private
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
index ea246128d..fed5e917d 100644
--- a/app/workers/pubsubhubbub/distribution_worker.rb
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -6,45 +6,27 @@ class Pubsubhubbub::DistributionWorker
   sidekiq_options queue: 'push'
 
   def perform(stream_entry_ids)
-    stream_entries = StreamEntry.where(id: stream_entry_ids).includes(:status).reject { |e| e.status&.direct_visibility? }
+    stream_entries = StreamEntry.where(id: stream_entry_ids).includes(:status).reject { |e| e.status.nil? || e.status.hidden? }
 
     return if stream_entries.empty?
 
     @account       = stream_entries.first.account
     @subscriptions = active_subscriptions.to_a
 
-    distribute_public!(stream_entries.reject(&:hidden?))
-    distribute_hidden!(stream_entries.select(&:hidden?))
+    distribute_public!(stream_entries)
   end
 
   private
 
   def distribute_public!(stream_entries)
-    return if stream_entries.empty?
-
     @payload = OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, stream_entries))
 
-    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription|
-      [subscription.id, @payload]
-    end
-  end
-
-  def distribute_hidden!(stream_entries)
-    return if stream_entries.empty?
-
-    @payload = OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, stream_entries))
-    @domains = @account.followers.domains
-
-    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions.select { |s| allowed_to_receive?(s.callback_url, s.domain) }) do |subscription|
-      [subscription.id, @payload]
+    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription_id|
+      [subscription_id, @payload]
     end
   end
 
   def active_subscriptions
-    Subscription.where(account: @account).active.select('id, callback_url, domain')
-  end
-
-  def allowed_to_receive?(callback_url, domain)
-    (!domain.nil? && @domains.include?(domain)) || @domains.include?(Addressable::URI.parse(callback_url).host)
+    Subscription.where(account: @account).active.pluck(:id)
   end
 end
diff --git a/app/workers/pubsubhubbub/raw_distribution_worker.rb b/app/workers/pubsubhubbub/raw_distribution_worker.rb
new file mode 100644
index 000000000..16962a623
--- /dev/null
+++ b/app/workers/pubsubhubbub/raw_distribution_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::RawDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(xml, source_account_id)
+    @account       = Account.find(source_account_id)
+    @subscriptions = active_subscriptions.to_a
+
+    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription|
+      [subscription.id, xml]
+    end
+  end
+
+  private
+
+  def active_subscriptions
+    Subscription.where(account: @account).active.select('id, callback_url, domain')
+  end
+end
diff --git a/app/workers/pubsubhubbub/subscribe_worker.rb b/app/workers/pubsubhubbub/subscribe_worker.rb
index 7560c2671..e350973e1 100644
--- a/app/workers/pubsubhubbub/subscribe_worker.rb
+++ b/app/workers/pubsubhubbub/subscribe_worker.rb
@@ -29,6 +29,6 @@ class Pubsubhubbub::SubscribeWorker
     logger.debug "PuSH re-subscribing to #{account.acct}"
     ::SubscribeService.new.call(account)
   rescue => e
-    raise e.class, "Subscribe failed for #{account&.acct}: #{e.message}"
+    raise e.class, "Subscribe failed for #{account&.acct}: #{e.message}", e.backtrace[0]
   end
 end
diff --git a/app/workers/pubsubhubbub/unsubscribe_worker.rb b/app/workers/pubsubhubbub/unsubscribe_worker.rb
new file mode 100644
index 000000000..a271715b7
--- /dev/null
+++ b/app/workers/pubsubhubbub/unsubscribe_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::UnsubscribeWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push', retry: false, unique: :until_executed, dead: false
+
+  def perform(account_id)
+    account = Account.find(account_id)
+    logger.debug "PuSH unsubscribing from #{account.acct}"
+    ::UnsubscribeService.new.call(account)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/refollow_worker.rb b/app/workers/refollow_worker.rb
new file mode 100644
index 000000000..66bcd27c3
--- /dev/null
+++ b/app/workers/refollow_worker.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class RefollowWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: false
+
+  def perform(target_account_id)
+    target_account = Account.find(target_account_id)
+    return unless target_account.protocol == :activitypub
+
+    target_account.followers.where(domain: nil).find_each do |follower|
+      # Locally unfollow remote account
+      follower.unfollow!(target_account)
+
+      # Schedule re-follow
+      begin
+        FollowService.new.call(follower, target_account)
+      rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError
+        next
+      end
+    end
+  end
+end
diff --git a/app/workers/resolve_remote_account_worker.rb b/app/workers/resolve_remote_account_worker.rb
new file mode 100644
index 000000000..5dd84ccb6
--- /dev/null
+++ b/app/workers/resolve_remote_account_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ResolveRemoteAccountWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', unique: :until_executed
+
+  def perform(uri)
+    ResolveRemoteAccountService.new.call(uri)
+  end
+end
diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb
index 402eed7c6..dbebaa2c3 100644
--- a/app/workers/scheduler/feed_cleanup_scheduler.rb
+++ b/app/workers/scheduler/feed_cleanup_scheduler.rb
@@ -5,8 +5,6 @@ class Scheduler::FeedCleanupScheduler
   include Sidekiq::Worker
 
   def perform
-    logger.info 'Cleaning out home feeds of inactive users'
-
     redis.pipelined do
       inactive_users.pluck(:account_id).each do |account_id|
         redis.del(FeedManager.instance.key(:home, account_id))
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
new file mode 100644
index 000000000..9f1593c91
--- /dev/null
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+require 'sidekiq-scheduler'
+
+class Scheduler::IpCleanupScheduler
+  include Sidekiq::Worker
+
+  def perform
+    time_ago = 5.years.ago
+    SessionActivation.where('updated_at < ?', time_ago).destroy_all
+    User.where('last_sign_in_at < ?', time_ago).update_all(last_sign_in_ip: nil)
+  end
+end
diff --git a/app/workers/scheduler/media_cleanup_scheduler.rb b/app/workers/scheduler/media_cleanup_scheduler.rb
index a95f512be..ce32ce314 100644
--- a/app/workers/scheduler/media_cleanup_scheduler.rb
+++ b/app/workers/scheduler/media_cleanup_scheduler.rb
@@ -5,7 +5,6 @@ class Scheduler::MediaCleanupScheduler
   include Sidekiq::Worker
 
   def perform
-    logger.info 'Cleaning out unattached media attachments'
     unattached_media.find_each(&:destroy)
   end
 
diff --git a/app/workers/scheduler/subscriptions_cleanup_scheduler.rb b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb
new file mode 100644
index 000000000..3b9211e81
--- /dev/null
+++ b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'sidekiq-scheduler'
+
+class Scheduler::SubscriptionsCleanupScheduler
+  include Sidekiq::Worker
+
+  def perform
+    Subscription.expired.in_batches.delete_all
+  end
+end
diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb
index 7bfd002f4..469a3d2a6 100644
--- a/app/workers/scheduler/subscriptions_scheduler.rb
+++ b/app/workers/scheduler/subscriptions_scheduler.rb
@@ -7,8 +7,6 @@ class Scheduler::SubscriptionsScheduler
   include Sidekiq::Worker
 
   def perform
-    logger.info 'Queueing PuSH re-subscriptions'
-
     Pubsubhubbub::SubscribeWorker.push_bulk(expiring_accounts.pluck(:id))
   end