about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2018-05-10 00:28:54 +0200
committerGitHub <noreply@github.com>2018-05-10 00:28:54 +0200
commit34142ab29c33104793afe3199f102d84b83b9b57 (patch)
treeafba23a3f169d7445f80ffe555985a295c98f6e1 /app
parent143878d9dadd03347c54c9261b9bc754a1d0f5bc (diff)
parent5d8052e7156c913a551b923c51d508b1ea8837b3 (diff)
Merge pull request #472 from ThibG/glitch-soc/merge
Merge upstream changes
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/confirmations_controller.rb19
-rw-r--r--app/controllers/admin/reported_statuses_controller.rb19
-rw-r--r--app/controllers/admin/statuses_controller.rb35
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/pins_controller.rb4
-rw-r--r--app/controllers/settings/profiles_controller.rb2
-rw-r--r--app/helpers/settings_helper.rb1
-rw-r--r--app/helpers/stream_entries_helper.rb4
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js47
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss6
-rw-r--r--app/javascript/flavours/glitch/styles/components/metadata.scss32
-rw-r--r--app/javascript/flavours/glitch/styles/metadata.scss55
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js21
-rw-r--r--app/javascript/mastodon/actions/notifications.js8
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js46
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js13
-rw-r--r--app/javascript/mastodon/components/status.js2
-rw-r--r--app/javascript/mastodon/containers/card_container.js18
-rw-r--r--app/javascript/mastodon/containers/cards_container.js59
-rw-r--r--app/javascript/mastodon/containers/timeline_container.js12
-rw-r--r--app/javascript/mastodon/features/account/components/header.js22
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js70
-rw-r--r--app/javascript/mastodon/features/compose/index.js12
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js11
-rw-r--r--app/javascript/mastodon/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js2
-rw-r--r--app/javascript/mastodon/locales/ar.json2
-rw-r--r--app/javascript/mastodon/locales/co.json296
-rw-r--r--app/javascript/mastodon/locales/el.json128
-rw-r--r--app/javascript/mastodon/locales/eo.json28
-rw-r--r--app/javascript/mastodon/locales/fr.json24
-rw-r--r--app/javascript/mastodon/locales/oc.json4
-rw-r--r--app/javascript/mastodon/locales/sk.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_co.json2
-rw-r--r--app/javascript/mastodon/reducers/timelines.js2
-rw-r--r--app/javascript/mastodon/utils/html.js6
-rw-r--r--app/javascript/mastodon/utils/resize_image.js107
-rw-r--r--app/javascript/packs/public.js15
-rw-r--r--app/javascript/styles/mastodon/accounts.scss44
-rw-r--r--app/javascript/styles/mastodon/admin.scss3
-rw-r--r--app/javascript/styles/mastodon/components.scss31
-rw-r--r--app/javascript/styles/mastodon/containers.scss1
-rw-r--r--app/javascript/styles/mastodon/forms.scss4
-rw-r--r--app/lib/activitypub/tag_manager.rb4
-rw-r--r--app/lib/formatter.rb12
-rw-r--r--app/lib/ostatus/activity/creation.rb8
-rw-r--r--app/lib/ostatus/atom_serializer.rb1
-rw-r--r--app/lib/request.rb4
-rw-r--r--app/models/account.rb30
-rw-r--r--app/models/user.rb2
-rw-r--r--app/serializers/activitypub/actor_serializer.rb2
-rw-r--r--app/serializers/activitypub/block_serializer.rb2
-rw-r--r--app/serializers/activitypub/follow_serializer.rb2
-rw-r--r--app/serializers/rest/account_serializer.rb3
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb1
-rw-r--r--app/services/activitypub/process_account_service.rb9
-rw-r--r--app/services/activitypub/process_collection_service.rb3
-rw-r--r--app/services/fan_out_on_write_service.rb4
-rw-r--r--app/services/fetch_link_card_service.rb2
-rw-r--r--app/services/post_status_service.rb8
-rw-r--r--app/views/about/_administration.html.haml2
-rw-r--r--app/views/about/_contact.html.haml2
-rw-r--r--app/views/about/show.html.haml2
-rw-r--r--app/views/accounts/_grid_card.html.haml2
-rw-r--r--app/views/accounts/_header.html.haml29
-rw-r--r--app/views/accounts/_moved_strip.html.haml4
-rw-r--r--app/views/admin/account_moderation_notes/_account_moderation_note.html.haml15
-rw-r--r--app/views/admin/accounts/show.html.haml49
-rw-r--r--app/views/admin/reports/_account.html.haml2
-rw-r--r--app/views/admin/reports/_status.html.haml2
-rw-r--r--app/views/admin/statuses/index.html.haml52
-rw-r--r--app/views/auth/sessions/two_factor.html.haml7
-rw-r--r--app/views/authorize_follows/_card.html.haml2
-rw-r--r--app/views/remote_unfollows/_card.html.haml2
-rw-r--r--app/views/settings/exports/show.html.haml6
-rw-r--r--app/views/settings/profiles/show.html.haml3
-rw-r--r--app/views/shared/_landing_strip.html.haml2
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml2
-rw-r--r--app/views/stream_entries/_simple_status.html.haml2
-rw-r--r--app/views/stream_entries/_status.html.haml2
-rw-r--r--app/views/tags/show.html.haml2
81 files changed, 1050 insertions, 465 deletions
diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb
index 34dfb458e..8d3477e66 100644
--- a/app/controllers/admin/confirmations_controller.rb
+++ b/app/controllers/admin/confirmations_controller.rb
@@ -3,6 +3,7 @@
 module Admin
   class ConfirmationsController < BaseController
     before_action :set_user
+    before_action :check_confirmation, only: [:resend]
 
     def create
       authorize @user, :confirm?
@@ -11,10 +12,28 @@ module Admin
       redirect_to admin_accounts_path
     end
 
+    def resend
+      authorize @user, :confirm?
+
+      @user.resend_confirmation_instructions
+
+      log_action :confirm, @user
+
+      flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
+      redirect_to admin_accounts_path
+    end
+
     private
 
     def set_user
       @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
     end
+
+    def check_confirmation
+      if @user.confirmed?
+        flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
+        redirect_to admin_accounts_path
+      end
+    end
   end
 end
diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb
index 522f68c98..d3c2f5e9e 100644
--- a/app/controllers/admin/reported_statuses_controller.rb
+++ b/app/controllers/admin/reported_statuses_controller.rb
@@ -3,7 +3,6 @@
 module Admin
   class ReportedStatusesController < BaseController
     before_action :set_report
-    before_action :set_status, only: [:update, :destroy]
 
     def create
       authorize :status, :update?
@@ -14,20 +13,6 @@ module Admin
       redirect_to admin_report_path(@report)
     end
 
-    def update
-      authorize @status, :update?
-      @status.update!(status_params)
-      log_action :update, @status
-      redirect_to admin_report_path(@report)
-    end
-
-    def destroy
-      authorize @status, :destroy?
-      RemovalWorker.perform_async(@status.id)
-      log_action :destroy, @status
-      render json: @status
-    end
-
     private
 
     def status_params
@@ -51,9 +36,5 @@ module Admin
     def set_report
       @report = Report.find(params[:report_id])
     end
-
-    def set_status
-      @status = @report.statuses.find(params[:id])
-    end
   end
 end
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index d5787acfb..382bfc4a2 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -5,7 +5,6 @@ module Admin
     helper_method :current_params
 
     before_action :set_account
-    before_action :set_status, only: [:update, :destroy]
 
     PER_PAGE = 20
 
@@ -26,40 +25,18 @@ module Admin
     def create
       authorize :status, :update?
 
-      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account))
+      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
       flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_account_statuses_path(@account.id, current_params)
     end
 
-    def update
-      authorize @status, :update?
-      @status.update!(status_params)
-      log_action :update, @status
-      redirect_to admin_account_statuses_path(@account.id, current_params)
-    end
-
-    def destroy
-      authorize @status, :destroy?
-      RemovalWorker.perform_async(@status.id)
-      log_action :destroy, @status
-      render json: @status
-    end
-
     private
 
-    def status_params
-      params.require(:status).permit(:sensitive)
-    end
-
     def form_status_batch_params
       params.require(:form_status_batch).permit(:action, status_ids: [])
     end
 
-    def set_status
-      @status = @account.statuses.find(params[:id])
-    end
-
     def set_account
       @account = Account.find(params[:account_id])
     end
@@ -72,5 +49,15 @@ module Admin
         page: page > 1 && page,
       }.select { |_, value| value.present? }
     end
+
+    def action_from_button
+      if params[:nsfw_on]
+        'nsfw_on'
+      elsif params[:nsfw_off]
+        'nsfw_off'
+      elsif params[:delete]
+        'delete'
+      end
+    end
   end
 end
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index a3c4008e6..259d07be8 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -21,7 +21,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   private
 
   def account_params
-    params.permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value])
+    params.permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value])
   end
 
   def user_settings_params
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
index bba6a6f48..54f8be667 100644
--- a/app/controllers/api/v1/statuses/pins_controller.rb
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -39,7 +39,7 @@ class Api::V1::Statuses::PinsController < Api::BaseController
       adapter: ActivityPub::Adapter
     ).as_json
 
-    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account)
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id)
   end
 
   def distribute_remove_activity!
@@ -49,6 +49,6 @@ class Api::V1::Statuses::PinsController < Api::BaseController
       adapter: ActivityPub::Adapter
     ).as_json
 
-    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account)
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id)
   end
 end
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 2b8330f2e..f9b003dcd 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -24,7 +24,7 @@ class Settings::ProfilesController < Settings::BaseController
   private
 
   def account_params
-    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value])
+    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value])
   end
 
   def set_account
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index f78e5fbc3..f6d86a18e 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -6,6 +6,7 @@ module SettingsHelper
     ar: 'العربية',
     bg: 'Български',
     ca: 'Català',
+    co: 'Corsu',
     de: 'Deutsch',
     el: 'Ελληνικά',
     eo: 'Esperanto',
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index c6f12ecd4..707c8e26c 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -4,8 +4,8 @@ module StreamEntriesHelper
   EMBEDDED_CONTROLLER = 'statuses'
   EMBEDDED_ACTION = 'embed'
 
-  def display_name(account)
-    account.display_name.presence || account.username
+  def display_name(account, **options)
+    Formatter.instance.format_display_name(account, options)
   end
 
   def account_description(account)
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index 7a0a2dfa9..15bd6b365 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -38,6 +38,8 @@ export default class Header extends ImmutablePureComponent {
 
     let displayName = account.get('display_name_html');
     let fields      = account.get('fields');
+    let badge       = account.get('bot') ? (<div className='roles'><div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div></div>) : null;
+
     let info        = '';
     let mutingInfo  = '';
     let actionBtn   = '';
@@ -99,38 +101,31 @@ export default class Header extends ImmutablePureComponent {
 
             <span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} />
             <span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span>
+
+            {badge}
+
             <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
 
             {fields.size > 0 && (
-              <table className='account__header__fields'>
-                <tbody>
-                  {fields.map((pair, i) => (
-                    <tr key={i}>
-                      <th dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} />
-                      <td dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                   </tr>
-                  ))}
-                </tbody>
-              </table>
+              <div className='account__header__fields'>
+                {fields.map((pair, i) => (
+                  <dl key={i}>
+                    <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+                    <dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value')} />
+                 </dl>
+                ))}
+              </div>
             )}
 
             {fields.size == 0 && metadata.length && (
-              <table className='account__header__fields'>
-                <tbody>
-                  {(() => {
-                    let data = [];
-                    for (let i = 0; i < metadata.length; i++) {
-                      data.push(
-                        <tr key={i}>
-                          <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
-                          <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
-                        </tr>
-                      );
-                    }
-                    return data;
-                  })()}
-                </tbody>
-              </table>
+              <div className='account__header__fields'>
+                {metadata.map((pair, i) => (
+                  <dl key={i}>
+                    <dt dangerouslySetInnerHTML={{ __html: emojify(pair[0]) }} title={pair[0]} />
+                    <dd dangerouslySetInnerHTML={{ __html: emojify(pair[1]) }} title={pair[1]} />
+                  </dl>
+                ))}
+              </div>
             ) || null}
 
             {info}
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 84d3f6ade..5167a507e 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -509,3 +509,9 @@
     margin-bottom: 0;
   }
 }
+
+.account__header .roles {
+  margin-top: 20px;
+  margin-bottom: 20px;
+  padding: 0 15px;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/metadata.scss b/app/javascript/flavours/glitch/styles/components/metadata.scss
index fa1a4bc34..29a6330e9 100644
--- a/app/javascript/flavours/glitch/styles/components/metadata.scss
+++ b/app/javascript/flavours/glitch/styles/components/metadata.scss
@@ -2,7 +2,6 @@
   font-size: 15px;
   line-height: 20px;
   overflow: hidden;
-  border-collapse: collapse;
   margin: 20px -10px -20px;
   border-bottom: 0;
 
@@ -14,35 +13,36 @@
     }
   }
 
-  tr {
+  dl {
     border-top: 1px solid lighten($ui-base-color, 8%);
-    text-align: center;
+    display: flex;
   }
 
-  th, td {
+  dt,
+  dd {
+    box-sizing: border-box;
     padding: 14px 20px;
-    vertical-align: middle;
-
-    & > div {
-      max-height: 40px;
-      overflow-y: auto;
-      white-space: pre-wrap;
-      text-overflow: ellipsis;
-    }
+    text-align: center;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     color: $darker-text-color;
     background: lighten($ui-base-color, 13%);
-    max-width: 120px;
+    width: 120px;
+    flex: 0 0 auto;
+    font-weight: 500;
 
     a {
       color: $primary-text-color;
     }
   }
 
-  td {
-    flex: auto;
+  dd {
+    flex: 1 1 auto;
     color: $primary-text-color;
     background: $ui-base-color;
 
diff --git a/app/javascript/flavours/glitch/styles/metadata.scss b/app/javascript/flavours/glitch/styles/metadata.scss
index b66cce3c1..280848959 100644
--- a/app/javascript/flavours/glitch/styles/metadata.scss
+++ b/app/javascript/flavours/glitch/styles/metadata.scss
@@ -1,43 +1,56 @@
 .account__header__fields {
   $meta-table-border: lighten($ui-base-color, 8%);
-
-  border-collapse: collapse;
   padding: 0;
   margin: 15px -15px -15px -15px;
   border: 0 none;
   border-top: 1px solid $meta-table-border;
   border-bottom: 1px solid $meta-table-border;
+  font-size: 14px;
+  line-height: 20px;
 
-  td, th {
-    padding: 15px;
-    border: 0 none;
+  dl {
+    display: flex;
     border-bottom: 1px solid $meta-table-border;
-    vertical-align: middle;
   }
 
-  tr:last-child {
-    td, th {
-      border-bottom: 0 none;
-    }
-  }
-
-  td {
-    color: $ui-primary-color;
+  dt,
+  dd {
+    box-sizing: border-box;
+    padding: 14px;
     text-align: center;
-    width:100%;
-    padding-left: 0;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     padding-left: 15px;
-    font-weight: bold;
+    font-weight: 500;
     text-align: center;
-    width: 94px;
-    color: $ui-secondary-color;
+    width: 120px;
+    flex: 0 0 auto;
+    color: $secondary-text-color;
     background: darken($ui-base-color, 8%);
   }
 
+  dd {
+    flex: 1 1 auto;
+    color: $darker-text-color;
+  }
+
   a {
-    color: $classic-highlight-color;
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+
+  dl:last-child {
+    border-bottom: 0;
   }
 }
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 5f1274fab..c015d3a99 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -1,20 +1,29 @@
 import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../../features/emoji/emoji';
+import { unescapeHTML } from '../../utils/html';
 
 const domParser = new DOMParser();
 
+const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
+  obj[`:${emoji.shortcode}:`] = emoji;
+  return obj;
+}, {});
+
 export function normalizeAccount(account) {
   account = { ...account };
 
+  const emojiMap = makeEmojiMap(account);
   const displayName = account.display_name.length === 0 ? account.username : account.display_name;
-  account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
-  account.note_emojified = emojify(account.note);
+
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
+  account.note_emojified = emojify(account.note, emojiMap);
 
   if (account.fields) {
     account.fields = account.fields.map(pair => ({
       ...pair,
       name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
-      value_emojified: emojify(pair.value),
+      value_emojified: emojify(pair.value, emojiMap),
+      value_plain: unescapeHTML(pair.value),
     }));
   }
 
@@ -42,11 +51,7 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.hidden = normalOldStatus.get('hidden');
   } else {
     const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
-
-    const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
-      obj[`:${emoji.shortcode}:`] = emoji;
-      return obj;
-    }, {});
+    const emojiMap = makeEmojiMap(normalStatus);
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 7aa070f56..393268811 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -8,6 +8,7 @@ import {
   importFetchedStatuses,
 } from './importer';
 import { defineMessages } from 'react-intl';
+import { unescapeHTML } from '../utils/html';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -31,13 +32,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
   }
 };
 
-const unescapeHTML = (html) => {
-  const wrapper = document.createElement('div');
-  html = html.replace(/<br \/>|<br>|\n/g, ' ');
-  wrapper.innerHTML = html;
-  return wrapper.textContent;
-};
-
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
     const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 982d34718..0a6e7c627 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -43,6 +43,7 @@ class DropdownMenu extends React.PureComponent {
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+    if (this.focusedItem) this.focusedItem.focus();
     this.setState({ mounted: true });
   }
 
@@ -55,6 +56,46 @@ class DropdownMenu extends React.PureComponent {
     this.node = c;
   }
 
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
+  handleKeyDown = e => {
+    const items = Array.from(this.node.getElementsByTagName('a'));
+    const index = items.indexOf(e.currentTarget);
+    let element;
+
+    switch(e.key) {
+    case 'Enter':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = items[index+1];
+      if (element) {
+        element.focus();
+      }
+      break;
+    case 'ArrowUp':
+      element = items[index-1];
+      if (element) {
+        element.focus();
+      }
+      break;
+    case 'Home':
+      element = items[0];
+      if (element) {
+        element.focus();
+      }
+      break;
+    case 'End':
+      element = items[items.length-1];
+      if (element) {
+        element.focus();
+      }
+      break;
+    }
+  }
+
   handleClick = e => {
     const i = Number(e.currentTarget.getAttribute('data-index'));
     const { action, to } = this.props.items[i];
@@ -79,7 +120,7 @@ class DropdownMenu extends React.PureComponent {
 
     return (
       <li className='dropdown-menu__item' key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
+        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleKeyDown} data-index={i}>
           {text}
         </a>
       </li>
@@ -156,9 +197,6 @@ export default class Dropdown extends React.PureComponent {
 
   handleKeyDown = e => {
     switch(e.key) {
-    case 'Enter':
-      this.handleClick(e);
-      break;
     case 'Escape':
       this.handleClose();
       break;
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index 7cdd63910..fd6858d05 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -35,7 +35,6 @@ export default class ScrollableList extends PureComponent {
 
   state = {
     fullscreen: null,
-    mouseOver: false,
   };
 
   intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -72,7 +71,7 @@ export default class ScrollableList extends PureComponent {
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
       this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
-    if (someItemInserted && this.node.scrollTop > 0 || (this.state.mouseOver && !prevProps.isLoading)) {
+    if (someItemInserted && this.node.scrollTop > 0) {
       return this.node.scrollHeight - this.node.scrollTop;
     } else {
       return null;
@@ -140,14 +139,6 @@ export default class ScrollableList extends PureComponent {
     this.props.onLoadMore();
   }
 
-  handleMouseEnter = () => {
-    this.setState({ mouseOver: true });
-  }
-
-  handleMouseLeave = () => {
-    this.setState({ mouseOver: false });
-  }
-
   render () {
     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
@@ -158,7 +149,7 @@ export default class ScrollableList extends PureComponent {
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
       scrollableArea = (
-        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
           <div role='feed' className='item-list'>
             {prepend}
 
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 402d558c4..953d98c20 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -206,7 +206,7 @@ export default class Status extends ImmutablePureComponent {
         );
       } else {
         media = (
-          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
+          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
             {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
           </Bundle>
         );
diff --git a/app/javascript/mastodon/containers/card_container.js b/app/javascript/mastodon/containers/card_container.js
deleted file mode 100644
index 11b9f88d4..000000000
--- a/app/javascript/mastodon/containers/card_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Card from '../features/status/components/card';
-import { fromJS } from 'immutable';
-
-export default class CardContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string,
-    card: PropTypes.array.isRequired,
-  };
-
-  render () {
-    const { card, ...props } = this.props;
-    return <Card card={fromJS(card)} {...props} />;
-  }
-
-}
diff --git a/app/javascript/mastodon/containers/cards_container.js b/app/javascript/mastodon/containers/cards_container.js
new file mode 100644
index 000000000..894bf4ef9
--- /dev/null
+++ b/app/javascript/mastodon/containers/cards_container.js
@@ -0,0 +1,59 @@
+import React, { Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import Card from '../features/status/components/card';
+import ModalRoot from '../components/modal_root';
+import MediaModal from '../features/ui/components/media_modal';
+import { fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class CardsContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string,
+    cards: PropTypes.object.isRequired,
+  };
+
+  state = {
+    media: null,
+  };
+
+  handleOpenCard = (media) => {
+    document.body.classList.add('card-standalone__body');
+    this.setState({ media });
+  }
+
+  handleCloseCard = () => {
+    document.body.classList.remove('card-standalone__body');
+    this.setState({ media: null });
+  }
+
+  render () {
+    const { locale, cards } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Fragment>
+          {[].map.call(cards, container => {
+            const { card, ...props } = JSON.parse(container.getAttribute('data-props'));
+
+            return ReactDOM.createPortal(
+              <Card card={fromJS(card)} onOpenMedia={this.handleOpenCard} {...props} />,
+              container,
+            );
+          })}
+          <ModalRoot onClose={this.handleCloseCard}>
+            {this.state.media && (
+              <MediaModal media={this.state.media} index={0} onClose={this.handleCloseCard} />
+            )}
+          </ModalRoot>
+        </Fragment>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
index 8719bb5c9..a1a4bd024 100644
--- a/app/javascript/mastodon/containers/timeline_container.js
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -1,4 +1,5 @@
-import React from 'react';
+import React, { Fragment } from 'react';
+import ReactDOM from 'react-dom';
 import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
 import configureStore from '../store/configureStore';
@@ -8,6 +9,7 @@ import { getLocale } from '../locales';
 import PublicTimeline from '../features/standalone/public_timeline';
 import CommunityTimeline from '../features/standalone/community_timeline';
 import HashtagTimeline from '../features/standalone/hashtag_timeline';
+import ModalContainer from '../features/ui/containers/modal_container';
 import initialState from '../initial_state';
 
 const { localeData, messages } = getLocale();
@@ -47,7 +49,13 @@ export default class TimelineContainer extends React.PureComponent {
     return (
       <IntlProvider locale={locale} messages={messages}>
         <Provider store={store}>
-          {timeline}
+          <Fragment>
+            {timeline}
+            {ReactDOM.createPortal(
+              <ModalContainer />,
+              document.getElementById('modal-container'),
+            )}
+          </Fragment>
         </Provider>
       </IntlProvider>
     );
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index bbf886dca..7358053da 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -131,6 +131,7 @@ export default class Header extends ImmutablePureComponent {
     const content         = { __html: account.get('note_emojified') };
     const displayNameHtml = { __html: account.get('display_name_html') };
     const fields          = account.get('fields');
+    const badge           = account.get('bot') ? (<div className='roles'><div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div></div>) : null;
 
     return (
       <div className={classNames('account__header', { inactive: !!account.get('moved') })} style={{ backgroundImage: `url(${account.get('header')})` }}>
@@ -139,19 +140,20 @@ export default class Header extends ImmutablePureComponent {
 
           <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
+
+          {badge}
+
           <div className='account__header__content' dangerouslySetInnerHTML={content} />
 
           {fields.size > 0 && (
-            <table className='account__header__fields'>
-              <tbody>
-                {fields.map((pair, i) => (
-                  <tr key={i}>
-                    <th dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} />
-                    <td dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                  </tr>
-                ))}
-              </tbody>
-            </table>
+            <div className='account__header__fields'>
+              {fields.map((pair, i) => (
+                <dl key={i}>
+                  <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+                  <dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value_plain')} />
+                </dl>
+              ))}
+            </div>
           )}
 
           {info}
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 6b22ba84a..a772c1c95 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -42,22 +42,65 @@ class PrivacyDropdownMenu extends React.PureComponent {
     }
   }
 
-  handleClick = e => {
-    if (e.key === 'Escape') {
-      this.props.onClose();
-    } else if (!e.key || e.key === 'Enter') {
-      const value = e.currentTarget.getAttribute('data-index');
-
-      e.preventDefault();
+  handleKeyDown = e => {
+    const { items } = this.props;
+    const value = e.currentTarget.getAttribute('data-index');
+    const index = items.findIndex(item => {
+      return (item.value === value);
+    });
+    let element;
 
+    switch(e.key) {
+    case 'Escape':
       this.props.onClose();
-      this.props.onChange(value);
+      break;
+    case 'Enter':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1];
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1];
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+      }
+      break;
     }
   }
 
+  handleClick = e => {
+    const value = e.currentTarget.getAttribute('data-index');
+
+    e.preventDefault();
+
+    this.props.onClose();
+    this.props.onChange(value);
+  }
+
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+    if (this.focusedItem) this.focusedItem.focus();
     this.setState({ mounted: true });
   }
 
@@ -70,6 +113,10 @@ class PrivacyDropdownMenu extends React.PureComponent {
     this.node = c;
   }
 
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
   render () {
     const { mounted } = this.state;
     const { style, items, value } = this.props;
@@ -80,9 +127,9 @@ class PrivacyDropdownMenu extends React.PureComponent {
           // It should not be transformed when mounting because the resulting
           // size will be used to determine the coordinate of the menu by
           // react-overlays
-          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
+          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
             {items.map(item => (
-              <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
+              <div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
                 <div className='privacy-dropdown__option__icon'>
                   <i className={`fa fa-fw fa-${item.icon}`} />
                 </div>
@@ -147,9 +194,6 @@ export default class PrivacyDropdown extends React.PureComponent {
 
   handleKeyDown = e => {
     switch(e.key) {
-    case 'Enter':
-      this.handleToggle(e);
-      break;
     case 'Escape':
       this.handleClose();
       break;
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 67f0e7981..19aae0332 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -43,11 +43,19 @@ export default class Compose extends React.PureComponent {
   };
 
   componentDidMount () {
-    this.props.dispatch(mountCompose());
+    const { isSearchPage } = this.props;
+
+    if (!isSearchPage) {
+      this.props.dispatch(mountCompose());
+    }
   }
 
   componentWillUnmount () {
-    this.props.dispatch(unmountCompose());
+    const { isSearchPage } = this.props;
+
+    if (!isSearchPage) {
+      this.props.dispatch(unmountCompose());
+    }
   }
 
   onFocus = () => {
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 4185cba32..a334318ce 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -40,6 +40,17 @@ export default class ModalRoot extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
+  getSnapshotBeforeUpdate () {
+    const visible = !!this.props.type;
+    return {
+      overflowY: visible ? 'hidden' : null,
+    };
+  }
+
+  componentDidUpdate (prevProps, prevState, { overflowY }) {
+    document.body.style.overflowY = overflowY;
+  }
+
   renderLoading = modalId => () => {
     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
   }
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js
index 8a55c553c..8616f0315 100644
--- a/app/javascript/mastodon/features/ui/components/report_modal.js
+++ b/app/javascript/mastodon/features/ui/components/report_modal.js
@@ -30,7 +30,7 @@ const makeMapStateToProps = () => {
       account: getAccount(state, accountId),
       comment: state.getIn(['reports', 'new', 'comment']),
       forward: state.getIn(['reports', 'new', 'forward']),
-      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
     };
   };
 
@@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(expandAccountTimeline(this.props.account.get('id')));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id')));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
     }
   }
 
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
index 4efacda65..e5b1edc4a 100644
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -21,6 +21,8 @@ const makeGetStatusIds = () => createSelector([
   }
 
   return statusIds.filter(id => {
+    if (id === null) return true;
+
     const statusForId = statuses.get(id);
     let showStatus    = true;
 
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 947348f70..d531d8fd5 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -258,7 +258,7 @@
   "status.pin": "تدبيس على الملف الشخصي",
   "status.pinned": "تبويق مثبَّت",
   "status.reblog": "رَقِّي",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "القيام بالترقية إلى الجمهور الأصلي",
   "status.reblogged_by": "{name} رقى",
   "status.reply": "ردّ",
   "status.replyAll": "رُد على الخيط",
diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json
new file mode 100644
index 000000000..2d7427c55
--- /dev/null
+++ b/app/javascript/mastodon/locales/co.json
@@ -0,0 +1,296 @@
+{
+  "account.block": "Bluccà @{name}",
+  "account.block_domain": "Piattà tuttu da {domain}",
+  "account.blocked": "Bluccatu",
+  "account.direct": "Missaghju direttu @{name}",
+  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.domain_blocked": "Duminiu piattatu",
+  "account.edit_profile": "Mudificà u prufile",
+  "account.follow": "Siguità",
+  "account.followers": "Abbunati",
+  "account.follows": "Abbunamenti",
+  "account.follows_you": "Vi seguita",
+  "account.hide_reblogs": "Piattà spartere da @{name}",
+  "account.media": "Media",
+  "account.mention": "Mintuvà @{name}",
+  "account.moved_to": "{name} hè partutu nant'à:",
+  "account.mute": "Piattà @{name}",
+  "account.mute_notifications": "Piattà nutificazione da @{name}",
+  "account.muted": "Piattatu",
+  "account.posts": "Statuti",
+  "account.posts_with_replies": "Statuti è risposte",
+  "account.report": "Palisà @{name}",
+  "account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
+  "account.share": "Sparte u prufile di @{name}",
+  "account.show_reblogs": "Vede spartere da @{name}",
+  "account.unblock": "Sbluccà @{name}",
+  "account.unblock_domain": "Ùn piattà più {domain}",
+  "account.unfollow": "Ùn siguità più",
+  "account.unmute": "Ùn piattà più @{name}",
+  "account.unmute_notifications": "Ùn piattà più nutificazione da @{name}",
+  "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
+  "alert.unexpected.title": "Uups!",
+  "boost_modal.combo": "Pudete appughjà nant'à {combo} per saltà quessa a prussima volta",
+  "bundle_column_error.body": "C'hè statu un prublemu caricandu st'elementu.",
+  "bundle_column_error.retry": "Pruvà torna",
+  "bundle_column_error.title": "Errore di cunnessione",
+  "bundle_modal_error.close": "Chjudà",
+  "bundle_modal_error.message": "C'hè statu un prublemu caricandu st'elementu.",
+  "bundle_modal_error.retry": "Pruvà torna",
+  "column.blocks": "Utilizatori bluccati",
+  "column.community": "Linea pubblica lucale",
+  "column.direct": "Missaghji diretti",
+  "column.domain_blocks": "Duminii piattati",
+  "column.favourites": "Favuriti",
+  "column.follow_requests": "Dumande d'abbunamentu",
+  "column.home": "Accolta",
+  "column.lists": "Liste",
+  "column.mutes": "Utilizatori piattati",
+  "column.notifications": "Nutificazione",
+  "column.pins": "Statuti puntarulati",
+  "column.public": "Linea pubblica glubale",
+  "column_back_button.label": "Ritornu",
+  "column_header.hide_settings": "Piattà i parametri",
+  "column_header.moveLeft_settings": "Spiazzà à manca",
+  "column_header.moveRight_settings": "Spiazzà à diritta",
+  "column_header.pin": "Puntarulà",
+  "column_header.show_settings": "Mustrà i parametri",
+  "column_header.unpin": "Spuntarulà",
+  "column_subheading.navigation": "Navigazione",
+  "column_subheading.settings": "Parametri",
+  "compose_form.direct_message_warning": "Solu l'utilizatori mintuvati puderenu vede stu statutu.",
+  "compose_form.hashtag_warning": "Stu statutu ùn hè \"Micca listatu\" è ùn sarà micca listatu indè e circate da hashtag. Per esse vistu in quesse, u statutu deve esse \"Pubblicu\".",
+  "compose_form.lock_disclaimer": "U vostru contu ùn hè micca {locked}. Tuttu u mondu pò seguitavi è vede i vostri statuti privati.",
+  "compose_form.lock_disclaimer.lock": "privatu",
+  "compose_form.placeholder": "À chè pensate?",
+  "compose_form.publish": "Toot",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.marked": "Media indicatu cum'è sensibile",
+  "compose_form.sensitive.unmarked": "Media micca indicatu cum'è sensibile",
+  "compose_form.spoiler.marked": "Testu piattatu daret'à un'avertimentu",
+  "compose_form.spoiler.unmarked": "Testu micca piattatu",
+  "compose_form.spoiler_placeholder": "Scrive u vostr'avertimentu quì",
+  "confirmation_modal.cancel": "Annullà",
+  "confirmations.block.confirm": "Bluccà",
+  "confirmations.block.message": "Site sicuru·a che vulete bluccà @{name}?",
+  "confirmations.delete.confirm": "Toglie",
+  "confirmations.delete.message": "Site sicuru·a che vulete supprime stu statutu?",
+  "confirmations.delete_list.confirm": "Toglie",
+  "confirmations.delete_list.message": "Site sicuru·a che vulete supprime sta lista?",
+  "confirmations.domain_block.confirm": "Piattà tuttu u duminiu?",
+  "confirmations.domain_block.message": "Site sicuru·a che vulete piattà tuttu à {domain}? Saria forse abbastanza di bluccà ò piattà alcuni conti da quallà.",
+  "confirmations.mute.confirm": "Piattà",
+  "confirmations.mute.message": "Site sicuru·a che vulete piattà @{name}?",
+  "confirmations.unfollow.confirm": "Disabbunassi",
+  "confirmations.unfollow.message": "Site sicuru·a ch'ùn vulete più siguità @{name}?",
+  "embed.instructions": "Integrà stu statutu à u vostru situ cù u codice quì sottu.",
+  "embed.preview": "Assumiglierà à qualcosa cusì:",
+  "emoji_button.activity": "Attività",
+  "emoji_button.custom": "Persunalizati",
+  "emoji_button.flags": "Bandere",
+  "emoji_button.food": "Manghjusca è Bienda",
+  "emoji_button.label": "Mette un'emoji",
+  "emoji_button.nature": "Natura",
+  "emoji_button.not_found": "Ùn c'hè nunda! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Oggetti",
+  "emoji_button.people": "Parsunaghji",
+  "emoji_button.recent": "Assai utilizati",
+  "emoji_button.search": "Cercà...",
+  "emoji_button.search_results": "Risultati di a cerca",
+  "emoji_button.symbols": "Simbuli",
+  "emoji_button.travel": "Lochi è Viaghju",
+  "empty_column.community": "Ùn c'hè nunda indè a linea lucale. Scrivete puru qualcosa!",
+  "empty_column.direct": "Ùn avete ancu nisun missaghju direttu. S'è voi mandate o ricevete unu, u vidarete quì.",
+  "empty_column.hashtag": "Ùn c'hè ancu nunda quì.",
+  "empty_column.home": "A vostr'accolta hè viota! Pudete andà nant'à {public} o pruvà a ricerca per truvà parsone da siguità.",
+  "empty_column.home.public_timeline": "a linea pubblica",
+  "empty_column.list": "Ùn c'hè ancu nunda quì. Quandu membri di sta lista manderanu novi statuti, i vidarete quì.",
+  "empty_column.notifications": "Ùn avete ancu nisuna nutificazione. Interact with others to start the conversation.",
+  "empty_column.public": "Ùn c'hè nunda quì! Scrivete qualcosa in pubblicu o seguitate utilizatori d'altre istanze per empie a linea pubblica.",
+  "follow_request.authorize": "Auturizà",
+  "follow_request.reject": "Righjittà",
+  "getting_started.appsshort": "Applicazione",
+  "getting_started.faq": "FAQ",
+  "getting_started.heading": "Per principià",
+  "getting_started.open_source_notice": "Mastodon ghjè un lugiziale liberu. Pudete cuntribuisce à u codice o a traduzione, o palisà un bug, nant'à GitHub: {github}",
+  "getting_started.userguide": "Guida d'utilizazione",
+  "home.column_settings.advanced": "Avanzati",
+  "home.column_settings.basic": "Bàsichi",
+  "home.column_settings.filter_regex": "Filtrà cù spressione regulare (regex)",
+  "home.column_settings.show_reblogs": "Vede e spartere",
+  "home.column_settings.show_replies": "Vede e risposte",
+  "home.settings": "Parametri di a colonna",
+  "keyboard_shortcuts.back": "rivultà",
+  "keyboard_shortcuts.boost": "sparte",
+  "keyboard_shortcuts.column": "fucalizà un statutu indè una colonna",
+  "keyboard_shortcuts.compose": "fucalizà nant'à l'area di ridazzione",
+  "keyboard_shortcuts.description": "Descrizzione",
+  "keyboard_shortcuts.down": "falà indè a lista",
+  "keyboard_shortcuts.enter": "apre u statutu",
+  "keyboard_shortcuts.favourite": "aghjunghje à i favuriti",
+  "keyboard_shortcuts.heading": "Accorte cù a tastera",
+  "keyboard_shortcuts.hotkey": "Accorta",
+  "keyboard_shortcuts.legend": "vede a legenda",
+  "keyboard_shortcuts.mention": "mintuvà l'autore",
+  "keyboard_shortcuts.reply": "risponde",
+  "keyboard_shortcuts.search": "fucalizà nant'à l'area di circata",
+  "keyboard_shortcuts.toggle_hidden": "vede/piattà u testu daretu à l'avertimentu CW",
+  "keyboard_shortcuts.toot": "scrive un novu statutu",
+  "keyboard_shortcuts.unfocus": "ùn fucalizà più l'area di testu",
+  "keyboard_shortcuts.up": "cullà indè a lista",
+  "lightbox.close": "Chjudà",
+  "lightbox.next": "Siguente",
+  "lightbox.previous": "Pricidente",
+  "lists.account.add": "Aghjunghje à a lista",
+  "lists.account.remove": "Toglie di a lista",
+  "lists.delete": "Supprime a lista",
+  "lists.edit": "Mudificà a lista",
+  "lists.new.create": "Aghjustà una lista",
+  "lists.new.title_placeholder": "Titulu di a lista",
+  "lists.search": "Circà indè i vostr'abbunamenti",
+  "lists.subheading": "E vo liste",
+  "loading_indicator.label": "Caricamentu...",
+  "media_gallery.toggle_visible": "Cambià a visibilità",
+  "missing_indicator.label": "Micca trovu",
+  "missing_indicator.sublabel": "Ùn era micca pussivule di truvà sta risorsa",
+  "mute_modal.hide_notifications": "Piattà nutificazione da st'utilizatore?",
+  "navigation_bar.blocks": "Utilizatori bluccati",
+  "navigation_bar.community_timeline": "Linea pubblica lucale",
+  "navigation_bar.direct": "Missaghji diretti",
+  "navigation_bar.domain_blocks": "Duminii piattati",
+  "navigation_bar.edit_profile": "Mudificà u prufile",
+  "navigation_bar.favourites": "Favuriti",
+  "navigation_bar.follow_requests": "Dumande d'abbunamentu",
+  "navigation_bar.info": "À prupositu di l'istanza",
+  "navigation_bar.keyboard_shortcuts": "Accorte cù a tastera",
+  "navigation_bar.lists": "Liste",
+  "navigation_bar.logout": "Scunnettassi",
+  "navigation_bar.mutes": "Utilizatori piattati",
+  "navigation_bar.pins": "Statuti puntarulati",
+  "navigation_bar.preferences": "Preferenze",
+  "navigation_bar.public_timeline": "Linea pubblica glubale",
+  "notification.favourite": "{name} hà aghjuntu u vostru statutu à i so favuriti",
+  "notification.follow": "{name} v'hà seguitatu",
+  "notification.mention": "{name} v'hà mintuvatu",
+  "notification.reblog": "{name} hà spartutu u vostru statutu",
+  "notifications.clear": "Purgà e nutificazione",
+  "notifications.clear_confirmation": "Site sicuru·a che vulete toglie tutte ste nutificazione?",
+  "notifications.column_settings.alert": "Nutificazione nant'à l'urdinatore",
+  "notifications.column_settings.favourite": "Favuriti:",
+  "notifications.column_settings.follow": "Abbunati novi:",
+  "notifications.column_settings.mention": "Minzione:",
+  "notifications.column_settings.push": "Nutificazione Push",
+  "notifications.column_settings.push_meta": "Quess'apparechju",
+  "notifications.column_settings.reblog": "Spartere:",
+  "notifications.column_settings.show": "Mustrà indè a colonna",
+  "notifications.column_settings.sound": "Sunà",
+  "onboarding.done": "Fatta",
+  "onboarding.next": "Siguente",
+  "onboarding.page_five.public_timelines": "A linea pubblica lucale mostra statuti pubblichi da tuttu u mondu nant'à {domain}. A linea pubblica glubale mostra ancu quelli di a ghjente seguitata da l'utilizatori di {domain}. Quesse sò una bona manera d'incuntrà nove parsone.",
+  "onboarding.page_four.home": "A linea d'accolta mostra i statuti di i vostr'abbunamenti.",
+  "onboarding.page_four.notifications": "A colonna di nutificazione mostra l'interazzione ch'altre parsone anu cù u vostru contu.",
+  "onboarding.page_one.federation": "Mastodon ghjè una rete di servori independenti, chjamati istanze, uniti indè una sola rete suciale.",
+  "onboarding.page_one.full_handle": "U vostru identificatore cumplettu",
+  "onboarding.page_one.handle_hint": "Quessu ghjè cio chì direte à i vostri amichi per circavi.",
+  "onboarding.page_one.welcome": "Benvenuti/a nant'à Mastodon!",
+  "onboarding.page_six.admin": "L'amministratore di a vostr'istanza hè {admin}.",
+  "onboarding.page_six.almost_done": "Quasgi finitu...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "Ci sò {apps} dispunibule per iOS, Android è altre piattaforme.",
+  "onboarding.page_six.github": "Mastodon ghjè un lugiziale liberu. Pudete cuntribuisce à u codice o a traduzione, o palisà un prublemu, nant'à {github}.",
+  "onboarding.page_six.guidelines": "regule di a cumunità",
+  "onboarding.page_six.read_guidelines": "Ùn vi scurdate di leghje e {guidelines} di {domain}",
+  "onboarding.page_six.various_app": "applicazione pè u telefuninu",
+  "onboarding.page_three.profile": "Pudete mudificà u prufile per cambia u ritrattu, a descrizzione è u nome affissatu. Ci sò ancu alcun'altre preferenze.",
+  "onboarding.page_three.search": "Fate usu di l'area di ricerca per truvà altre persone è vede hashtag cum'è {illustration} o {introductions}. Per vede qualcunu ch'ùn hè micca nant'à st'istanza, cercate u so identificatore complettu (pare un'email).",
+  "onboarding.page_two.compose": "I statuti è missaghji si scrivenu indè l'area di ridazzione. Pudete caricà imagine, cambià i parametri di pubblicazione, è mette avertimenti di cuntenuti cù i buttoni quì sottu.",
+  "onboarding.skip": "Passà",
+  "privacy.change": "Mudificà a cunfidenzialità di u statutu",
+  "privacy.direct.long": "Mandà solu à quelli chì so mintuvati",
+  "privacy.direct.short": "Direttu",
+  "privacy.private.long": "Mustrà solu à l'abbunati",
+  "privacy.private.short": "Privatu",
+  "privacy.public.long": "Mustrà à tuttu u mondu nant'a linea pubblica",
+  "privacy.public.short": "Pubblicu",
+  "privacy.unlisted.long": "Ùn mette micca nant'a linea pubblica (ma tutt'u mondu pò vede u statutu nant'à u vostru prufile)",
+  "privacy.unlisted.short": "Micca listatu",
+  "regeneration_indicator.label": "Caricamentu…",
+  "regeneration_indicator.sublabel": "Priparazione di a vostra pagina d'accolta",
+  "relative_time.days": "{number}d",
+  "relative_time.hours": "{number}h",
+  "relative_time.just_now": "avà",
+  "relative_time.minutes": "{number}m",
+  "relative_time.seconds": "{number}s",
+  "reply_indicator.cancel": "Annullà",
+  "report.forward": "Trasferisce à {target}",
+  "report.forward_hint": "U contu hè nant'à un'altru servore. Vulete ancu mandà una copia anonima di u signalamentu quallà?",
+  "report.hint": "U signalamentu sarà mandatu à i muderatori di l'istanza. Pudete spiegà perchè avete palisatu stu contu quì sottu:",
+  "report.placeholder": "Altri cummenti",
+  "report.submit": "Mandà",
+  "report.target": "Signalamentu",
+  "search.placeholder": "Circà",
+  "search_popout.search_format": "Ricerca avanzata",
+  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "statutu",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "utilizatore",
+  "search_results.accounts": "Ghjente",
+  "search_results.hashtags": "Hashtags",
+  "search_results.statuses": "Statuti",
+  "search_results.total": "{count, number} {count, plural, one {risultatu} other {risultati}}",
+  "standalone.public_title": "Una vista di...",
+  "status.block": "Bluccà @{name}",
+  "status.cancel_reblog_private": "Ùn sparte più",
+  "status.cannot_reblog": "Stu statutu ùn pò micca esse spartutu",
+  "status.delete": "Toglie",
+  "status.direct": "Mandà un missaghju @{name}",
+  "status.embed": "Integrà",
+  "status.favourite": "Aghjunghje à i favuriti",
+  "status.load_more": "Vede di più",
+  "status.media_hidden": "Media piattata",
+  "status.mention": "Mintuvà @{name}",
+  "status.more": "Più",
+  "status.mute": "Piattà @{name}",
+  "status.mute_conversation": "Piattà a cunversazione",
+  "status.open": "Apre stu statutu",
+  "status.pin": "Puntarulà à u prufile",
+  "status.pinned": "Statutu puntarulatu",
+  "status.reblog": "Sparte",
+  "status.reblog_private": "Sparte à l'audienza uriginale",
+  "status.reblogged_by": "{name} hà spartutu",
+  "status.reply": "Risponde",
+  "status.replyAll": "Risponde à tutti",
+  "status.report": "Palisà @{name}",
+  "status.sensitive_toggle": "Cliccate per vede",
+  "status.sensitive_warning": "Cuntinutu sensibile",
+  "status.share": "Sparte",
+  "status.show_less": "Ripiegà",
+  "status.show_less_all": "Ripiegà tuttu",
+  "status.show_more": "Slibrà",
+  "status.show_more_all": "Slibrà tuttu",
+  "status.unmute_conversation": "Ùn piattà più a cunversazione",
+  "status.unpin": "Spuntarulà da u prufile",
+  "tabs_bar.federated_timeline": "Glubale",
+  "tabs_bar.home": "Accolta",
+  "tabs_bar.local_timeline": "Lucale",
+  "tabs_bar.notifications": "Nutificazione",
+  "tabs_bar.search": "Cercà",
+  "ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.",
+  "upload_area.title": "Drag & drop per caricà un fugliale",
+  "upload_button.label": "Aghjunghje un media",
+  "upload_form.description": "Discrive per i malvistosi",
+  "upload_form.focus": "Riquatrà",
+  "upload_form.undo": "Annullà",
+  "upload_progress.label": "Caricamentu...",
+  "video.close": "Chjudà a video",
+  "video.exit_fullscreen": "Caccià u pienu screnu",
+  "video.expand": "Ingrandà a video",
+  "video.fullscreen": "Pienu screnu",
+  "video.hide": "Piattà a video",
+  "video.mute": "Surdina",
+  "video.pause": "Pausa",
+  "video.play": "Lettura",
+  "video.unmute": "Caccià a surdina"
+}
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
index a7e1c408f..5b36348eb 100644
--- a/app/javascript/mastodon/locales/el.json
+++ b/app/javascript/mastodon/locales/el.json
@@ -2,9 +2,9 @@
   "account.block": "Απόκλεισε τον/την @{name}",
   "account.block_domain": "Απόκρυψε τα πάντα από τον/την",
   "account.blocked": "Αποκλεισμένος/η",
-  "account.direct": "Απευθείας μήνυμα προς @{name}",
+  "account.direct": "Άμεσο μήνυμα προς @{name}",
   "account.disclaimer_full": "Οι παρακάτω πληροφορίες μπορει να μην αντανακλούν το προφίλ του χρήστη επαρκως.",
-  "account.domain_blocked": "Domain hidden",
+  "account.domain_blocked": "Κρυμμένος τομέας",
   "account.edit_profile": "Επεξεργάσου το προφίλ",
   "account.follow": "Ακολούθησε",
   "account.followers": "Ακόλουθοι",
@@ -23,15 +23,15 @@
   "account.requested": "Εκκρεμεί έγκριση. Κάνε κλικ για να ακυρώσεις το αίτημα ακολούθησης",
   "account.share": "Μοιράσου το προφίλ του/της @{name}",
   "account.show_reblogs": "Δείξε τις προωθήσεις του/της @{name}",
-  "account.unblock": "Unblock @{name}",
+  "account.unblock": "Ξεμπλόκαρε τον/την @{name}",
   "account.unblock_domain": "Αποκάλυψε το {domain}",
-  "account.unfollow": "Unfollow",
-  "account.unmute": "Unmute @{name}",
-  "account.unmute_notifications": "Unmute notifications from @{name}",
+  "account.unfollow": "Διακοπή παρακολούθησης",
+  "account.unmute": "Διακοπή αποσιώπησης του/της @{name}",
+  "account.unmute_notifications": "Διακοπή αποσιώπησης ειδοποιήσεων του/της @{name}",
   "account.view_full_profile": "Δες το πλήρες προφίλ",
   "alert.unexpected.message": "Προέκυψε απροσδόκητο σφάλμα.",
   "alert.unexpected.title": "Εεπ!",
-  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "boost_modal.combo": "Μπορείς να πατήσεις {combo} για να το προσπεράσεις αυτό την επόμενη φορά",
   "bundle_column_error.body": "Κάτι πήγε στραβά ενώ φορτωνόταν αυτό το στοιχείο.",
   "bundle_column_error.retry": "Δοκίμασε ξανά",
   "bundle_column_error.title": "Σφάλμα δικτύου",
@@ -41,7 +41,7 @@
   "column.blocks": "Αποκλεισμένοι χρήστες",
   "column.community": "Τοπική ροή",
   "column.direct": "Απευθείας μηνύματα",
-  "column.domain_blocks": "Hidden domains",
+  "column.domain_blocks": "Κρυμμένοι τομείς",
   "column.favourites": "Αγαπημένα",
   "column.follow_requests": "Αιτήματα παρακολούθησης",
   "column.home": "Αρχική",
@@ -60,7 +60,7 @@
   "column_subheading.navigation": "Πλοήγηση",
   "column_subheading.settings": "Ρυθμίσεις",
   "compose_form.direct_message_warning": "Αυτό το τουτ θα εμφανίζεται μόνο σε όλους τους αναφερόμενους χρήστες.",
-  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.hashtag_warning": "Αυτό το τουτ δεν θα εμφανίζεται κάτω από καμία ταμπέλα καθώς είναι αφανές. Μόνο τα δημόσια τουτ μπορούν να αναζητηθούν ανά ταμπέλα.",
   "compose_form.lock_disclaimer": "Ο λογαριασμός σου δεν είναι {locked}. Οποιοσδήποτε μπορεί να σε ακολουθήσει για να δει τις δημοσιεύσεις σας προς τους ακολούθους σας.",
   "compose_form.lock_disclaimer.lock": "κλειδωμένος",
   "compose_form.placeholder": "Τι σκέφτεσαι;",
@@ -78,65 +78,65 @@
   "confirmations.delete.message": "Σίγουρα θες να διαγράψεις αυτή την κατάσταση;",
   "confirmations.delete_list.confirm": "Διέγραψε",
   "confirmations.delete_list.message": "Σίγουρα θες να διαγράψεις οριστικά αυτή τη λίστα;",
-  "confirmations.domain_block.confirm": "Hide entire domain",
-  "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
-  "confirmations.mute.confirm": "Mute",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
-  "embed.preview": "Here is what it will look like:",
-  "emoji_button.activity": "Activity",
-  "emoji_button.custom": "Custom",
-  "emoji_button.flags": "Flags",
-  "emoji_button.food": "Food & Drink",
-  "emoji_button.label": "Insert emoji",
-  "emoji_button.nature": "Nature",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.recent": "Frequently used",
-  "emoji_button.search": "Search...",
-  "emoji_button.search_results": "Search results",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
-  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
-  "empty_column.hashtag": "There is nothing in this hashtag yet.",
-  "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
-  "empty_column.home.public_timeline": "the public timeline",
-  "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
-  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
-  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
-  "follow_request.authorize": "Authorize",
-  "follow_request.reject": "Reject",
-  "getting_started.appsshort": "Apps",
+  "confirmations.domain_block.confirm": "Απόκρυψη ολόκληρου του τομέα",
+  "confirmations.domain_block.message": "Σίγουρα θες να μπλοκάρεις ολόκληρο το {domain}; Συνήθως μερικά εστιασμένα μπλοκ ή αποσιωπήσεις επαρκούν και προτιμούνται.",
+  "confirmations.mute.confirm": "Αποσιώπηση",
+  "confirmations.mute.message": "Σίγουρα θες να αποσιωπήσεις τον/την {name};",
+  "confirmations.unfollow.confirm": "Διακοπή παρακολούθησης",
+  "confirmations.unfollow.message": "Σίγουρα θες να πάψεις να ακολουθείς τον/την {name};",
+  "embed.instructions": "Ενσωματώστε αυτή την κατάσταση στην ιστοσελίδα σας αντιγράφοντας τον παρακάτω κώδικα.",
+  "embed.preview": "Ορίστε πως θα φαίνεται:",
+  "emoji_button.activity": "Δραστηριότητα",
+  "emoji_button.custom": "Προσαρμοσμένα",
+  "emoji_button.flags": "Σημαίες",
+  "emoji_button.food": "Φαγητά & Ποτά",
+  "emoji_button.label": "Εισάγετε emoji",
+  "emoji_button.nature": "Φύση",
+  "emoji_button.not_found": "Ουδέν emojo!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Αντικείμενα",
+  "emoji_button.people": "Άνθρωποι",
+  "emoji_button.recent": "Δημοφιλή",
+  "emoji_button.search": "Αναζήτηση…",
+  "emoji_button.search_results": "Αποτελέσματα αναζήτησης",
+  "emoji_button.symbols": "Σύμβολα",
+  "emoji_button.travel": "Ταξίδια & Τοποθεσίες",
+  "empty_column.community": "Η τοπική ροή είναι κενή. Γράψε κάτι δημόσιο παραμύθι ν' αρχινίσει!",
+  "empty_column.direct": "Δεν έχεις απευθείας μηνύματα ακόμα. Όταν στείλεις ή λάβεις κανένα, θα εμφανιστεί εδώ.",
+  "empty_column.hashtag": "Δεν υπάρχει ακόμα κάτι για αυτή την ταμπέλα.",
+  "empty_column.home": "Η τοπική σου ροή είναι κενή! Πήγαινε στο {public} ή κάνε αναζήτηση για να ξεκινήσεις και να γνωρίσεις άλλους χρήστες.",
+  "empty_column.home.public_timeline": "η δημόσια ροή",
+  "empty_column.list": "Δεν υπάρχει τίποτα σε αυτή τη λίστα ακόμα. Όταν τα μέλη της δημοσιεύσουν νέες καταστάσεις, θα εμφανιστούν εδώ",
+  "empty_column.notifications": "Δεν έχεις ειδοποιήσεις ακόμα. Αλληλεπίδρασε με άλλους χρήστες για να ξεκινήσεις την κουβέντα.",
+  "empty_column.public": "Δεν υπάρχει τίποτα εδώ! Γράψε κάτι δημόσιο, ή ακολούθησε χειροκίνητα χρήστες από άλλα instances για να το γεμίσεις",
+  "follow_request.authorize": "Ενέκρινε",
+  "follow_request.reject": "Απέρριψε",
+  "getting_started.appsshort": "Εφαρμογές",
   "getting_started.faq": "FAQ",
-  "getting_started.heading": "Getting started",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
-  "getting_started.userguide": "User Guide",
-  "home.column_settings.advanced": "Advanced",
-  "home.column_settings.basic": "Basic",
-  "home.column_settings.filter_regex": "Filter out by regular expressions",
-  "home.column_settings.show_reblogs": "Show boosts",
-  "home.column_settings.show_replies": "Show replies",
-  "home.settings": "Column settings",
-  "keyboard_shortcuts.back": "to navigate back",
-  "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
-  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "getting_started.heading": "Ξεκινώντας",
+  "getting_started.open_source_notice": "Το Mastodon είναι ελεύθερο λογισμικό. Μπορείς να συνεισφέρεις ή να αναφέρεις ζητήματα στο GitHub στο {github}.",
+  "getting_started.userguide": "Οδηγός Χρηστών",
+  "home.column_settings.advanced": "Προχωρημένα",
+  "home.column_settings.basic": "Βασικά",
+  "home.column_settings.filter_regex": "Φιλτράρετε μέσω regular expressions",
+  "home.column_settings.show_reblogs": "Εμφάνιση προωθήσεων",
+  "home.column_settings.show_replies": "Εμφάνιση απαντήσεων",
+  "home.settings": "Ρυθμίσεις στηλών",
+  "keyboard_shortcuts.back": "για επιστροφή πίσω",
+  "keyboard_shortcuts.boost": "για προώθηση",
+  "keyboard_shortcuts.column": "για εστίαση μιας κατάστασης σε μια από τις στήλες",
+  "keyboard_shortcuts.compose": "για εστίαση στην περιοχή κειμένου συγγραφής",
   "keyboard_shortcuts.description": "Description",
-  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.down": "για κίνηση προς τα κάτω στη λίστα",
   "keyboard_shortcuts.enter": "to open status",
-  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.favourite": "για σημείωση αγαπημένου",
   "keyboard_shortcuts.heading": "Keyboard Shortcuts",
-  "keyboard_shortcuts.hotkey": "Hotkey",
-  "keyboard_shortcuts.legend": "to display this legend",
-  "keyboard_shortcuts.mention": "to mention author",
-  "keyboard_shortcuts.reply": "to reply",
-  "keyboard_shortcuts.search": "to focus search",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
-  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.hotkey": "Συντόμευση",
+  "keyboard_shortcuts.legend": "για να εμφανίσεις αυτόν τον οδηγό",
+  "keyboard_shortcuts.mention": "για να αναφέρεις το συγγραφέα",
+  "keyboard_shortcuts.reply": "για απάντηση",
+  "keyboard_shortcuts.search": "για εστίαση αναζήτησης",
+  "keyboard_shortcuts.toggle_hidden": "για εμφάνιση/απόκρυψη κειμένου πίσω από την προειδοποίηση",
+  "keyboard_shortcuts.toot": "για δημιουργία ολοκαίνουριου τουτ",
   "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
   "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Close",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 37587c14c..1ab6d2aa0 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -2,7 +2,7 @@
   "account.block": "Bloki @{name}",
   "account.block_domain": "Kaŝi ĉion de {domain}",
   "account.blocked": "Blokita",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "Rekte mesaĝi @{name}",
   "account.disclaimer_full": "Subaj informoj povas reflekti la profilon de la uzanto nekomplete.",
   "account.domain_blocked": "Domajno kaŝita",
   "account.edit_profile": "Redakti profilon",
@@ -29,8 +29,8 @@
   "account.unmute": "Malsilentigi @{name}",
   "account.unmute_notifications": "Malsilentigi sciigojn de @{name}",
   "account.view_full_profile": "Vidi plenan profilon",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Neatendita eraro okazis.",
+  "alert.unexpected.title": "Ups!",
   "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje",
   "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.",
   "bundle_column_error.retry": "Bonvolu reprovi",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Bonvolu reprovi",
   "column.blocks": "Blokitaj uzantoj",
   "column.community": "Loka tempolinio",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Rektaj mesaĝoj",
+  "column.domain_blocks": "Kaŝitaj domajnoj",
   "column.favourites": "Stelumoj",
   "column.follow_requests": "Petoj de sekvado",
   "column.home": "Hejmo",
@@ -59,7 +59,7 @@
   "column_header.unpin": "Depingli",
   "column_subheading.navigation": "Navigado",
   "column_subheading.settings": "Agordado",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Tiu mesaĝo videblos nur por ĉiuj menciitaj uzantoj.",
   "compose_form.hashtag_warning": "Ĉi tiu mesaĝo ne estos listigita per ajna kradvorto. Nur publikaj mesaĝoj estas serĉeblaj per kradvortoj.",
   "compose_form.lock_disclaimer": "Via konta ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn, kiuj estas nur por sekvantoj.",
   "compose_form.lock_disclaimer.lock": "ŝlosita",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Simboloj",
   "emoji_button.travel": "Vojaĝoj kaj lokoj",
   "empty_column.community": "La loka tempolinio estas malplena. Skribu ion por plenigi ĝin!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Vi ankoraŭ ne havas rektan mesaĝon. Kiam vi sendos aŭ ricevos iun, ĝi aperos ĉi tie.",
   "empty_column.hashtag": "Ankoraŭ estas nenio per ĉi tiu kradvorto.",
   "empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.",
   "empty_column.home.public_timeline": "la publikan tempolinion",
@@ -135,7 +135,7 @@
   "keyboard_shortcuts.mention": "por mencii la aŭtoron",
   "keyboard_shortcuts.reply": "por respondi",
   "keyboard_shortcuts.search": "por fokusigi la serĉilon",
-  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
+  "keyboard_shortcuts.toggle_hidden": "por montri/kaŝi tekston malantaŭ enhava averto",
   "keyboard_shortcuts.toot": "por komenci tute novan mesaĝon",
   "keyboard_shortcuts.unfocus": "por malfokusigi la tekstujon aŭ la serĉilon",
   "keyboard_shortcuts.up": "por iri supren en la listo",
@@ -157,8 +157,8 @@
   "mute_modal.hide_notifications": "Ĉu vi volas kaŝi la sciigojn el ĉi tiu uzanto?",
   "navigation_bar.blocks": "Blokitaj uzantoj",
   "navigation_bar.community_timeline": "Loka tempolinio",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Rektaj mesaĝoj",
+  "navigation_bar.domain_blocks": "Kaŝitaj domajnoj",
   "navigation_bar.edit_profile": "Redakti profilon",
   "navigation_bar.favourites": "Stelumoj",
   "navigation_bar.follow_requests": "Petoj de sekvado",
@@ -242,10 +242,10 @@
   "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezultoj}}",
   "standalone.public_title": "Enrigardo…",
   "status.block": "Bloki @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Eksdiskonigi",
   "status.cannot_reblog": "Ĉi tiu mesaĝo ne diskonigeblas",
   "status.delete": "Forigi",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Rekte mesaĝi @{name}",
   "status.embed": "Enkorpigi",
   "status.favourite": "Stelumi",
   "status.load_more": "Ŝargi pli",
@@ -258,7 +258,7 @@
   "status.pin": "Alpingli profile",
   "status.pinned": "Alpinglita mesaĝo",
   "status.reblog": "Diskonigi",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Diskonigi al la originala atentaro",
   "status.reblogged_by": "{name} diskonigis",
   "status.reply": "Respondi",
   "status.replyAll": "Respondi al la fadeno",
@@ -276,7 +276,7 @@
   "tabs_bar.home": "Hejmo",
   "tabs_bar.local_timeline": "Loka tempolinio",
   "tabs_bar.notifications": "Sciigoj",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Serĉi",
   "ui.beforeunload": "Via malneto perdiĝos se vi eliras de Mastodon.",
   "upload_area.title": "Altreni kaj lasi por alŝuti",
   "upload_button.label": "Aldoni aŭdovidaĵon",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index a4af97dda..16bf4033c 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -13,7 +13,7 @@
   "account.hide_reblogs": "Masquer les partages de @{name}",
   "account.media": "Média",
   "account.mention": "Mentionner",
-  "account.moved_to": "{name} a déménagé vers :",
+  "account.moved_to": "{name} a déménagé vers :",
   "account.mute": "Masquer @{name}",
   "account.mute_notifications": "Ignorer les notifications de @{name}",
   "account.muted": "Silencé",
@@ -30,7 +30,7 @@
   "account.unmute_notifications": "Réactiver les notifications de @{name}",
   "account.view_full_profile": "Afficher le profil complet",
   "alert.unexpected.message": "Une erreur non-attendue s'est produite.",
-  "alert.unexpected.title": "Oups !",
+  "alert.unexpected.title": "Oups !",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois",
   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
   "bundle_column_error.retry": "Réessayer",
@@ -77,7 +77,7 @@
   "confirmations.delete.confirm": "Supprimer",
   "confirmations.delete.message": "Confirmez-vous la suppression de ce pouet ?",
   "confirmations.delete_list.confirm": "Supprimer",
-  "confirmations.delete_list.message": "Êtes-vous sûr de vouloir supprimer définitivement cette liste ?",
+  "confirmations.delete_list.message": "Êtes-vous sûr de vouloir supprimer définitivement cette liste ?",
   "confirmations.domain_block.confirm": "Masquer le domaine entier",
   "confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables.",
   "confirmations.mute.confirm": "Masquer",
@@ -85,14 +85,14 @@
   "confirmations.unfollow.confirm": "Ne plus suivre",
   "confirmations.unfollow.message": "Voulez-vous arrêter de suivre {name} ?",
   "embed.instructions": "Intégrez ce statut à votre site en copiant le code ci-dessous.",
-  "embed.preview": "Il apparaîtra comme cela :",
+  "embed.preview": "Il apparaîtra comme cela :",
   "emoji_button.activity": "Activités",
   "emoji_button.custom": "Personnalisés",
   "emoji_button.flags": "Drapeaux",
   "emoji_button.food": "Nourriture & Boisson",
   "emoji_button.label": "Insérer un émoji",
   "emoji_button.nature": "Nature",
-  "emoji_button.not_found": "Pas d'emojis !! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Pas d'emojis !! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objets",
   "emoji_button.people": "Personnages",
   "emoji_button.recent": "Fréquemment utilisés",
@@ -154,7 +154,7 @@
   "media_gallery.toggle_visible": "Modifier la visibilité",
   "missing_indicator.label": "Non trouvé",
   "missing_indicator.sublabel": "Ressource introuvable",
-  "mute_modal.hide_notifications": "Masquer les notifications de cette personne ?",
+  "mute_modal.hide_notifications": "Masquer les notifications de cette personne ?",
   "navigation_bar.blocks": "Comptes bloqués",
   "navigation_bar.community_timeline": "Fil public local",
   "navigation_bar.direct": "Messages directs",
@@ -177,9 +177,9 @@
   "notifications.clear": "Nettoyer les notifications",
   "notifications.clear_confirmation": "Voulez-vous vraiment supprimer toutes vos notifications ?",
   "notifications.column_settings.alert": "Notifications locales",
-  "notifications.column_settings.favourite": "Favoris :",
-  "notifications.column_settings.follow": "Nouveaux⋅elles abonné⋅e·s :",
-  "notifications.column_settings.mention": "Mentions :",
+  "notifications.column_settings.favourite": "Favoris :",
+  "notifications.column_settings.follow": "Nouveaux⋅elles abonné⋅e·s :",
+  "notifications.column_settings.mention": "Mentions :",
   "notifications.column_settings.push": "Notifications push",
   "notifications.column_settings.push_meta": "Cet appareil",
   "notifications.column_settings.reblog": "Partages :",
@@ -216,7 +216,7 @@
   "privacy.unlisted.long": "Ne pas afficher dans les fils publics",
   "privacy.unlisted.short": "Non-listé",
   "regeneration_indicator.label": "Chargement…",
-  "regeneration_indicator.sublabel": "Le flux de votre page principale est en cours de préparation !",
+  "regeneration_indicator.sublabel": "Le flux de votre page principale est en cours de préparation !",
   "relative_time.days": "{number} j",
   "relative_time.hours": "{number} h",
   "relative_time.just_now": "à l’instant",
@@ -224,8 +224,8 @@
   "relative_time.seconds": "{number} s",
   "reply_indicator.cancel": "Annuler",
   "report.forward": "Transférer à {target}",
-  "report.forward_hint": "Le compte provient d'un autre serveur. Envoyez également une copie anonyme du rapport ?",
-  "report.hint": "Le rapport sera envoyé aux modérateurs de votre instance. Vous pouvez expliquer pourquoi vous signalez ce compte ci-dessous :",
+  "report.forward_hint": "Le compte provient d'un autre serveur. Envoyez également une copie anonyme du rapport ?",
+  "report.hint": "Le rapport sera envoyé aux modérateurs de votre instance. Vous pouvez expliquer pourquoi vous signalez ce compte ci-dessous :",
   "report.placeholder": "Commentaires additionnels",
   "report.submit": "Envoyer",
   "report.target": "Signalement",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index d4836e9fe..c4cb996cf 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -110,7 +110,7 @@
   "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.appsshort": "Aplicacions",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Per començar",
   "getting_started.open_source_notice": "Mastodon es un logicial liure. Podètz contribuir e mandar vòstres comentaris e rapòrt de bug via {github} sus GitHub.",
@@ -158,7 +158,7 @@
   "navigation_bar.blocks": "Personas blocadas",
   "navigation_bar.community_timeline": "Flux public local",
   "navigation_bar.direct": "Messatges dirèctes",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.domain_blocks": "Domenis amagats",
   "navigation_bar.edit_profile": "Modificar lo perfil",
   "navigation_bar.favourites": "Favorits",
   "navigation_bar.follow_requests": "Demandas d’abonament",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index e5e826c96..d69648ccf 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -231,7 +231,7 @@
   "report.target": "Nahlásenie {target}",
   "search.placeholder": "Hľadaj",
   "search_popout.search_format": "Pokročilé vyhľadávanie",
-  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
+  "search_popout.tips.full_text": "Jednoduchý textový výpis statusov ktoré si napísal/a, ktoré si obľúbil/a, povýšil/a, alebo aj tých, v ktorých si bol/a spomenutý/á, a potom všetky zadaniu odpovedajúce prezívky, mená a haštagy.",
   "search_popout.tips.hashtag": "haštag",
   "search_popout.tips.status": "status",
   "search_popout.tips.text": "Jednoduchý text vráti zhodujúce sa mená, prezývky a hashtagy",
diff --git a/app/javascript/mastodon/locales/whitelist_co.json b/app/javascript/mastodon/locales/whitelist_co.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_co.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index dd675d78f..b09d78b0f 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -134,7 +134,7 @@ export default function timelines(state = initialState, action) {
       initialTimeline,
       map => map.update(
         'items',
-        items => items.first() ? items : items.unshift(null)
+        items => items.first() ? items.unshift(null) : items
       )
     );
   default:
diff --git a/app/javascript/mastodon/utils/html.js b/app/javascript/mastodon/utils/html.js
new file mode 100644
index 000000000..0b646ce58
--- /dev/null
+++ b/app/javascript/mastodon/utils/html.js
@@ -0,0 +1,6 @@
+export const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  html = html.replace(/<br \/>|<br>|\n/g, ' ');
+  wrapper.innerHTML = html;
+  return wrapper.textContent;
+};
diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js
index 6442eda38..54459de3e 100644
--- a/app/javascript/mastodon/utils/resize_image.js
+++ b/app/javascript/mastodon/utils/resize_image.js
@@ -1,3 +1,5 @@
+import EXIF from 'exif-js';
+
 const MAX_IMAGE_DIMENSION = 1280;
 
 const getImageUrl = inputFile => new Promise((resolve, reject) => {
@@ -28,6 +30,84 @@ const loadImage = inputFile => new Promise((resolve, reject) => {
   }).catch(reject);
 });
 
+const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
+  if (type !== 'image/jpeg') {
+    resolve(1);
+    return;
+  }
+
+  EXIF.getData(img, () => {
+    const orientation = EXIF.getTag(img, 'Orientation');
+    resolve(orientation);
+  });
+});
+
+const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => {
+  const canvas  = document.createElement('canvas');
+  [canvas.width, canvas.height] = orientation < 5 ? [width, height] : [height, width];
+
+  const context = canvas.getContext('2d');
+
+  switch (orientation) {
+  case 2:
+    context.translate(width, 0);
+    break;
+  case 3:
+    context.translate(width, height);
+    break;
+  case 4:
+    context.translate(0, height);
+    break;
+  case 5:
+    context.rotate(0.5 * Math.PI);
+    context.translate(1, -1);
+    break;
+  case 6:
+    context.rotate(0.5 * Math.PI);
+    context.translate(0, -height);
+    break;
+  case 7:
+    context.rotate(0.5, Math.PI);
+    context.translate(width, -height);
+    break;
+  case 8:
+    context.rotate(-0.5, Math.PI);
+    context.translate(-width, 0);
+    break;
+  }
+
+  context.drawImage(img, 0, 0, width, height);
+
+  canvas.toBlob(resolve, type);
+});
+
+const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => {
+  const { width, height } = img;
+
+  let newWidth, newHeight;
+
+  if (width > height) {
+    newHeight = height * MAX_IMAGE_DIMENSION / width;
+    newWidth  = MAX_IMAGE_DIMENSION;
+  } else if (height > width) {
+    newWidth  = width * MAX_IMAGE_DIMENSION / height;
+    newHeight = MAX_IMAGE_DIMENSION;
+  } else {
+    newWidth  = MAX_IMAGE_DIMENSION;
+    newHeight = MAX_IMAGE_DIMENSION;
+  }
+
+  getOrientation(img, type)
+    .then(orientation => processImage(img, {
+      width: newWidth,
+      height: newHeight,
+      orientation,
+      type,
+    }))
+    .then(resolve)
+    .catch(reject);
+});
+
 export default inputFile => new Promise((resolve, reject) => {
   if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
     resolve(inputFile);
@@ -35,32 +115,13 @@ export default inputFile => new Promise((resolve, reject) => {
   }
 
   loadImage(inputFile).then(img => {
-    const canvas = document.createElement('canvas');
-    const { width, height } = img;
-
-    let newWidth, newHeight;
-
-    if (width < MAX_IMAGE_DIMENSION && height < MAX_IMAGE_DIMENSION) {
+    if (img.width < MAX_IMAGE_DIMENSION && img.height < MAX_IMAGE_DIMENSION) {
       resolve(inputFile);
       return;
     }
 
-    if (width > height) {
-      newHeight = height * MAX_IMAGE_DIMENSION / width;
-      newWidth  = MAX_IMAGE_DIMENSION;
-    } else if (height > width) {
-      newWidth  = width * MAX_IMAGE_DIMENSION / height;
-      newHeight = MAX_IMAGE_DIMENSION;
-    } else {
-      newWidth  = MAX_IMAGE_DIMENSION;
-      newHeight = MAX_IMAGE_DIMENSION;
-    }
-
-    canvas.width  = newWidth;
-    canvas.height = newHeight;
-
-    canvas.getContext('2d').drawImage(img, 0, 0, newWidth, newHeight);
-
-    canvas.toBlob(resolve, inputFile.type);
+    resizeImage(img, inputFile.type)
+      .then(resolve)
+      .catch(() => resolve(inputFile));
   }).catch(reject);
 });
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3377c2329..38aaf895f 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -7,7 +7,6 @@ function main() {
   const { getLocale } = require('../mastodon/locales');
   const { localeData } = getLocale();
   const VideoContainer = require('../mastodon/containers/video_container').default;
-  const CardContainer = require('../mastodon/containers/card_container').default;
   const React = require('react');
   const ReactDOM = require('react-dom');
 
@@ -57,10 +56,16 @@ function main() {
       ReactDOM.render(<VideoContainer locale={locale} {...props} />, content);
     });
 
-    [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
-    });
+    const cards = document.querySelectorAll('[data-component="Card"]');
+
+    if (cards.length > 0) {
+      import(/* webpackChunkName: "containers/cards_container" */ '../mastodon/containers/cards_container').then(({ default: CardsContainer }) => {
+        const content = document.createElement('div');
+
+        ReactDOM.render(<CardsContainer locale={locale} cards={cards} />, content);
+        document.body.appendChild(content);
+      }).catch(error => console.error(error));
+    }
 
     const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]');
 
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index c2d0de4b9..b063ca52d 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -565,36 +565,41 @@
 }
 
 .account__header__fields {
-  border-collapse: collapse;
   padding: 0;
   margin: 15px -15px -15px;
   border: 0 none;
   border-top: 1px solid lighten($ui-base-color, 4%);
   border-bottom: 1px solid lighten($ui-base-color, 4%);
+  font-size: 14px;
+  line-height: 20px;
 
-  th,
-  td {
-    padding: 15px;
-    padding-left: 15px;
-    border: 0 none;
+  dl {
+    display: flex;
     border-bottom: 1px solid lighten($ui-base-color, 4%);
-    vertical-align: middle;
   }
 
-  th {
-    padding-left: 15px;
-    font-weight: 500;
+  dt,
+  dd {
+    box-sizing: border-box;
+    padding: 14px;
     text-align: center;
-    width: 94px;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  dt {
+    font-weight: 500;
+    width: 120px;
+    flex: 0 0 auto;
     color: $secondary-text-color;
     background: rgba(darken($ui-base-color, 8%), 0.5);
   }
 
-  td {
+  dd {
+    flex: 1 1 auto;
     color: $darker-text-color;
-    text-align: center;
-    width: 100%;
-    padding-left: 0;
   }
 
   a {
@@ -608,12 +613,7 @@
     }
   }
 
-  tr {
-    &:last-child {
-      th,
-      td {
-        border-bottom: 0;
-      }
-    }
+  dl:last-child {
+    border-bottom: 0;
   }
 }
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index a6cc8b62b..1948a2a23 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -336,7 +336,8 @@
   }
 }
 
-.simple_form.new_report_note {
+.simple_form.new_report_note,
+.simple_form.new_account_moderation_note {
   max-width: 100%;
 }
 
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index a982585c3..70ef96aa7 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -4033,7 +4033,7 @@ a.status-card {
 .report-modal__statuses {
   flex: 1 1 auto;
   min-height: 20vh;
-  max-height: 40vh;
+  max-height: 80vh;
   overflow-y: auto;
   overflow-x: hidden;
 
@@ -5159,38 +5159,45 @@ noscript {
   }
 }
 
+.account__header .roles {
+  margin-top: 20px;
+  margin-bottom: 20px;
+  padding: 0 15px;
+}
+
 .account__header .account__header__fields {
   font-size: 14px;
   line-height: 20px;
   overflow: hidden;
-  border-collapse: collapse;
   margin: 20px -10px -20px;
   border-bottom: 0;
 
-  tr {
+  dl {
     border-top: 1px solid lighten($ui-base-color, 8%);
-    text-align: center;
+    display: flex;
   }
 
-  th,
-  td {
+  dt,
+  dd {
+    box-sizing: border-box;
     padding: 14px 20px;
-    vertical-align: middle;
-    max-height: 40px;
+    text-align: center;
+    max-height: 48px;
     overflow: hidden;
     white-space: nowrap;
     text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     color: $darker-text-color;
     background: darken($ui-base-color, 4%);
-    max-width: 120px;
+    width: 120px;
+    flex: 0 0 auto;
     font-weight: 500;
   }
 
-  td {
-    flex: auto;
+  dd {
+    flex: 1 1 auto;
     color: $primary-text-color;
     background: $ui-base-color;
   }
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 9d5ab66a4..c40b38a5a 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -60,6 +60,7 @@
   }
 }
 
+.card-standalone__body,
 .media-gallery-standalone__body {
   overflow: hidden;
 }
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index f97890187..de16784a8 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -87,6 +87,10 @@ code {
       align-items: flex-start;
     }
 
+    &.file .label_input {
+      flex-wrap: nowrap;
+    }
+
     &.select .label_input {
       align-items: initial;
     }
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 908ea9639..95d1cf9f3 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -86,6 +86,8 @@ class ActivityPub::TagManager
   end
 
   def local_uri?(uri)
+    return false if uri.nil?
+
     uri  = Addressable::URI.parse(uri)
     host = uri.normalized_host
     host = "#{host}:#{uri.port}" if uri.port
@@ -99,6 +101,8 @@ class ActivityPub::TagManager
   end
 
   def uri_to_resource(uri, klass)
+    return if uri.nil?
+
     if local_uri?(uri)
       case klass.name
       when 'Account'
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 050c651ee..e1ab05cc0 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -67,9 +67,17 @@ class Formatter
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
-  def format_field(account, str)
+  def format_display_name(account, **options)
+    html = encode(account.display_name.presence || account.username)
+    html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  def format_field(account, str, **options)
     return reformat(str).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
-    encode_and_link_urls(str, me: true).html_safe # rubocop:disable Rails/OutputSafety
+    html = encode_and_link_urls(str, me: true)
+    html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
+    html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
   def linkify(text)
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index 1e7f47029..dbccc8330 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -46,7 +46,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
         visibility: visibility_scope,
         conversation: find_or_create_conversation,
         thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil,
-        media_attachment_ids: media_attachments.map(&:id)
+        media_attachment_ids: media_attachments.map(&:id),
+        sensitive: sensitive?
       )
 
       save_mentions(status)
@@ -105,6 +106,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
 
   private
 
+  def sensitive?
+    # OStatus-specific convention (not standard)
+    @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).any? { |category| category['term'] == 'nsfw' }
+  end
+
   def find_or_create_conversation
     uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content
     return if uri.nil?
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 7c66f2066..698f2ee22 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -368,6 +368,7 @@ class OStatus::AtomSerializer
       append_element(entry, 'link', nil, rel: :enclosure, type: media.file_content_type, length: media.file_file_size, href: full_asset_url(media.file.url(:original, false)))
     end
 
+    append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive? && status.media_attachments.any?
     append_element(entry, 'mastodon:scope', status.visibility)
 
     status.emojis.each do |emoji|
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 00f94dacf..fc7d398e0 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -51,7 +51,7 @@ class Request
   end
 
   def headers
-    (@account ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET)
+    (@account ? @headers.merge('Signature' => signature) : @headers).reverse_merge('Accept-Encoding' => 'gzip').without(REQUEST_TARGET)
   end
 
   private
@@ -100,7 +100,7 @@ class Request
   end
 
   def http_client
-    @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2)
+    @http_client ||= HTTP.use(:auto_inflate).timeout(:per_operation, timeout).follow(max_hops: 2)
   end
 
   def use_proxy?
diff --git a/app/models/account.rb b/app/models/account.rb
index c1ce1e99e..06c190446 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -45,6 +45,7 @@
 #  moved_to_account_id     :bigint(8)
 #  featured_collection_url :string
 #  fields                  :jsonb
+#  actor_type              :string
 #
 
 class Account < ApplicationRecord
@@ -76,6 +77,7 @@ class Account < ApplicationRecord
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
   validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
   validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? }
+  validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
 
   # Timelines
   has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -151,6 +153,16 @@ class Account < ApplicationRecord
     moved_to_account_id.present?
   end
 
+  def bot?
+    %w(Application Service).include? actor_type
+  end
+
+  alias bot bot?
+
+  def bot=(val)
+    self.actor_type = ActiveModel::Type::Boolean.new.cast(val) ? 'Service' : 'Person'
+  end
+
   def acct
     local? ? username : "#{username}@#{domain}"
   end
@@ -201,9 +213,11 @@ class Account < ApplicationRecord
   def fields_attributes=(attributes)
     fields = []
 
-    attributes.each_value do |attr|
-      next if attr[:name].blank?
-      fields << attr
+    if attributes.is_a?(Hash)
+      attributes.each_value do |attr|
+        next if attr[:name].blank?
+        fields << attr
+      end
     end
 
     self[:fields] = fields
@@ -272,8 +286,8 @@ class Account < ApplicationRecord
 
     def initialize(account, attr)
       @account = account
-      @name    = attr['name']
-      @value   = attr['value']
+      @name    = attr['name'].strip[0, 255]
+      @value   = attr['value'].strip[0, 255]
       @errors  = {}
     end
 
@@ -398,7 +412,7 @@ class Account < ApplicationRecord
   end
 
   def emojis
-    @emojis ||= CustomEmoji.from_text(note, domain)
+    @emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
   end
 
   before_create :generate_keys
@@ -441,4 +455,8 @@ class Account < ApplicationRecord
 
     self.domain = TagManager.instance.normalize_domain(domain)
   end
+
+  def emojifiable_text
+    [note, display_name, fields.map(&:value)].join(' ')
+  end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 24beb77b2..82d165079 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,7 +41,7 @@ class User < ApplicationRecord
   include Settings::Extend
   include Omniauthable
 
-  ACTIVE_DURATION = 14.days
+  ACTIVE_DURATION = 7.days
 
   devise :two_factor_authenticatable,
          otp_secret_encryption_key: Rails.configuration.x.otp_secret
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index fcf3bdf17..41c9aa44e 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -37,7 +37,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def type
-    'Person'
+    object.bot? ? 'Service' : 'Person'
   end
 
   def following
diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb
index b3bd9f868..624ce2fce 100644
--- a/app/serializers/activitypub/block_serializer.rb
+++ b/app/serializers/activitypub/block_serializer.rb
@@ -5,7 +5,7 @@ class ActivityPub::BlockSerializer < ActiveModel::Serializer
   attribute :virtual_object, key: :object
 
   def id
-    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
+    ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
   end
 
   def type
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index 24dfe96f8..bb204ee8f 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -5,7 +5,7 @@ class ActivityPub::FollowSerializer < ActiveModel::Serializer
   attribute :virtual_object, key: :object
 
   def id
-    ActivityPub::TagManager.instance.uri_for(object)
+    ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
   end
 
   def type
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 863238eb7..6adcd7039 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -3,11 +3,12 @@
 class REST::AccountSerializer < ActiveModel::Serializer
   include RoutingHelper
 
-  attributes :id, :username, :acct, :display_name, :locked, :created_at,
+  attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
              :note, :url, :avatar, :avatar_static, :header, :header_static,
              :followers_count, :following_count, :statuses_count
 
   has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
+  has_many :emojis, serializer: REST::CustomEmojiSerializer
 
   class FieldSerializer < ActiveModel::Serializer
     attributes :name, :value
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index 930fbad1f..b6c00a9e7 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -34,6 +34,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   end
 
   def trustworthy_attribution?(uri, attributed_to)
+    return false if uri.nil? || attributed_to.nil?
     Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
   end
 
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index f67ebb443..721c9c928 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -71,6 +71,7 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.note                    = @json['summary'] || ''
     @account.locked                  = @json['manuallyApprovesFollowers'] || false
     @account.fields                  = property_values || {}
+    @account.actor_type              = actor_type
   end
 
   def set_fetchable_attributes!
@@ -95,6 +96,14 @@ class ActivityPub::ProcessAccountService < BaseService
     ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
   end
 
+  def actor_type
+    if @json['type'].is_a?(Array)
+      @json['type'].find { |type| ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(type) }
+    else
+      @json['type']
+    end
+  end
+
   def image_url(key)
     value = first_of_value(@json[key])
 
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
index eb93329e9..79cdca297 100644
--- a/app/services/activitypub/process_collection_service.rb
+++ b/app/services/activitypub/process_collection_service.rb
@@ -45,5 +45,8 @@ class ActivityPub::ProcessCollectionService < BaseService
 
   def verify_account!
     @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+  rescue JSON::LD::JsonLdError => e
+    Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}"
+    nil
   end
 end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 510b80c82..cb82a79ed 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -37,7 +37,7 @@ class FanOutOnWriteService < BaseService
   def deliver_to_followers(status)
     Rails.logger.debug "Delivering status #{status.id} to followers"
 
-    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |followers|
+    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).select(:id).reorder(nil).find_in_batches do |followers|
       FeedInsertWorker.push_bulk(followers) do |follower|
         [status.id, follower.id, :home]
       end
@@ -47,7 +47,7 @@ class FanOutOnWriteService < BaseService
   def deliver_to_lists(status)
     Rails.logger.debug "Delivering status #{status.id} to lists"
 
-    status.account.lists.joins(account: :user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |lists|
+    status.account.lists.joins(account: :user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).select(:id).reorder(nil).find_in_batches do |lists|
       FeedInsertWorker.push_bulk(lists) do |list|
         [status.id, list.id, :list]
       end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 77d4aa538..f9b1b2f0c 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -27,7 +27,7 @@ class FetchLinkCardService < BaseService
     end
 
     attach_card if @card&.persisted?
-  rescue HTTP::Error, Addressable::URI::InvalidURIError => e
+  rescue HTTP::Error, Addressable::URI::InvalidURIError, Mastodon::LengthValidationError => e
     Rails.logger.debug "Error fetching link #{@url}: #{e}"
     nil
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index fe03c044c..6eb233f9d 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -31,12 +31,12 @@ class PostStatusService < BaseService
                                         sensitive: (options[:sensitive].nil? ? account.user&.setting_default_sensitive : options[:sensitive]),
                                         spoiler_text: options[:spoiler_text] || '',
                                         visibility: options[:visibility] || account.user&.setting_default_privacy,
-                                        language: LanguageDetector.instance.detect(text, account),
+                                        language: language_from_option(options[:language]) || LanguageDetector.instance.detect(text, account),
                                         application: options[:application])
     end
 
-    process_mentions_service.call(status)
     process_hashtags_service.call(status)
+    process_mentions_service.call(status)
 
     LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
     DistributionWorker.perform_async(status.id)
@@ -68,6 +68,10 @@ class PostStatusService < BaseService
     media
   end
 
+  def language_from_option(str)
+    ISO_639.find(str)&.alpha2
+  end
+
   def process_mentions_service
     ProcessMentionsService.new
   end
diff --git a/app/views/about/_administration.html.haml b/app/views/about/_administration.html.haml
index ec5834f9c..02286d68b 100644
--- a/app/views/about/_administration.html.haml
+++ b/app/views/about/_administration.html.haml
@@ -6,7 +6,7 @@
           .account__avatar{ style: "background-image: url(#{@instance_presenter.contact_account.avatar.url})" }
         %span.display-name
           %bdi
-            %strong.display-name__html.emojify= display_name(@instance_presenter.contact_account)
+            %strong.display-name__html.emojify= display_name(@instance_presenter.contact_account, custom_emojify: true)
           %span.display-name__account @#{@instance_presenter.contact_account.acct}
     - else
       .account__display-name
diff --git a/app/views/about/_contact.html.haml b/app/views/about/_contact.html.haml
index cf21ad5a3..3215d50b5 100644
--- a/app/views/about/_contact.html.haml
+++ b/app/views/about/_contact.html.haml
@@ -12,7 +12,7 @@
         .avatar= image_tag contact.contact_account.avatar.url
         .name
           = link_to TagManager.instance.url_for(contact.contact_account) do
-            %span.display_name.emojify= display_name(contact.contact_account)
+            %span.display_name.emojify= display_name(contact.contact_account, custom_emojify: true)
             %span.username @#{contact.contact_account.acct}
     - else
       .owner
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index e264c8574..e6d4cd10e 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -141,3 +141,5 @@
             %p
               = link_to t('about.source_code'), @instance_presenter.source_url
               = " (#{@instance_presenter.version_number})"
+
+#modal-container
diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml
index 95acbd581..a59ed128e 100644
--- a/app/views/accounts/_grid_card.html.haml
+++ b/app/views/accounts/_grid_card.html.haml
@@ -5,7 +5,7 @@
     .avatar= image_tag account.avatar.url(:original)
   .name
     = link_to TagManager.instance.url_for(account) do
-      %span.display_name.emojify= display_name(account)
+      %span.display_name.emojify= display_name(account, custom_emojify: true)
       %span.username
         @#{account.local? ? account.local_username_and_domain : account.acct}
         = fa_icon('lock') if account.locked?
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index af79922c2..652213526 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -6,11 +6,16 @@
 
   .card__bio
     %h1.name
-      %span.p-name.emojify= display_name(account)
+      %span.p-name.emojify= display_name(account, custom_emojify: true)
       %small<
         %span>< @#{account.local_username_and_domain}
         = fa_icon('lock') if account.locked?
-    - if Setting.show_staff_badge
+
+    - if account.bot?
+      .roles
+        .account-role.bot
+          = t 'accounts.roles.bot'
+    - elsif Setting.show_staff_badge
       - if account.user_admin?
         .roles
           .account-role.admin
@@ -21,19 +26,19 @@
             = t 'accounts.roles.moderator'
     .bio
       .account__header__content.p-note.emojify!=processed_bio[:text]
+
       - if !account.fields.empty?
-        %table.account__header__fields
-          %tbody
-            - account.fields.each do |field|
-              %tr
-                %th.emojify= field.name
-                %td.emojify= Formatter.instance.format_field(account, field.value)
+        .account__header__fields
+          - account.fields.each do |field|
+            %dl
+              %dt.emojify{ title: field.name }= field.name
+              %dd.emojify{ title: field.value }= Formatter.instance.format_field(account, field.value, custom_emojify: true)
       - elsif processed_bio[:metadata].length > 0
-        %table.account__header__fields<
+        .account__header__fields
           - processed_bio[:metadata].each do |i|
-            %tr
-              %th.emojify>!=i[0]
-              %td.emojify>!=i[1]
+            %dl
+              %dt.emojify{ title: i[0] }= i[0]
+              %dd.emojify{ title: i[1] }= i[1]
 
     .details-counters
       .counter{ class: active_nav_class(short_account_url(account)) }
diff --git a/app/views/accounts/_moved_strip.html.haml b/app/views/accounts/_moved_strip.html.haml
index 6a14a5dd3..ae18c6dc7 100644
--- a/app/views/accounts/_moved_strip.html.haml
+++ b/app/views/accounts/_moved_strip.html.haml
@@ -3,7 +3,7 @@
 .moved-strip
   .moved-strip__message
     = fa_icon 'suitcase'
-    = t('accounts.moved_html', name: content_tag(:strong, display_name(account), class: :emojify), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.acct)])), TagManager.instance.url_for(moved_to_account), class: 'mention'))
+    = t('accounts.moved_html', name: content_tag(:strong, display_name(account, custom_emojify: true), class: :emojify), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.acct)])), TagManager.instance.url_for(moved_to_account), class: 'mention'))
 
   .moved-strip__card
     = link_to TagManager.instance.url_for(moved_to_account), class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
@@ -13,5 +13,5 @@
           .account__avatar-overlay-overlay{ style: "background-image: url('#{account.avatar.url(:original)}')" }
 
       %span.display-name
-        %strong.emojify= display_name(moved_to_account)
+        %strong.emojify= display_name(moved_to_account, custom_emojify: true)
         %span @#{moved_to_account.acct}
diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
index 6761a4319..432fb79a6 100644
--- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
+++ b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
@@ -1,10 +1,7 @@
-%tr
-  %td
+.speech-bubble
+  .speech-bubble__bubble
     = simple_format(h(account_moderation_note.content))
-  %td
-    = account_moderation_note.account.acct
-  %td
-    %time.formatted{ datetime: account_moderation_note.created_at.iso8601, title: l(account_moderation_note.created_at) }
-      = l account_moderation_note.created_at
-  %td
-    = link_to t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
+  .speech-bubble__owner
+    = admin_account_link_to account_moderation_note.account
+    %time.formatted{ datetime: account_moderation_note.created_at.iso8601 }= l account_moderation_note.created_at
+    = table_link_to 'trash', t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 7312618ee..ed8190af5 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -2,7 +2,7 @@
   = @account.acct
 
 .table-wrapper
-  %table.table
+  %table.table.inline-table
     %tbody
       %tr
         %th= t('admin.accounts.username')
@@ -36,14 +36,20 @@
           %th= t('admin.accounts.email')
           %td
             = @account.user_email
-            - if @account.user_confirmed?
-              = fa_icon('check')
             = table_link_to 'edit', t('admin.accounts.change_email.label'), admin_account_change_email_path(@account.id) if can?(:change_email, @account.user)
         - if @account.user_unconfirmed_email.present?
           %th= t('admin.accounts.unconfirmed_email')
           %td
             = @account.user_unconfirmed_email
         %tr
+          %th= t('admin.accounts.email_status')
+          %td
+            - if @account.user&.confirmed?
+              = t('admin.accounts.confirmed')
+            - else
+              = t('admin.accounts.confirming')
+              = table_link_to 'refresh', t('admin.accounts.resend_confirmation.send'), resend_admin_account_confirmation_path(@account.id), method: :post if can?(:confirm, @account.user)
+        %tr
           %th= t('admin.accounts.login_status')
           %td
             - if @account.user&.disabled?
@@ -73,17 +79,17 @@
 
       %tr
         %th= t('admin.accounts.follows')
-        %td= @account.following_count
+        %td= number_to_human @account.following_count
       %tr
         %th= t('admin.accounts.followers')
-        %td= @account.followers_count
+        %td= number_to_human @account.followers_count
       %tr
         %th= t('admin.accounts.statuses')
-        %td= link_to @account.statuses_count, admin_account_statuses_path(@account.id)
+        %td= link_to number_to_human(@account.statuses_count), admin_account_statuses_path(@account.id)
       %tr
         %th= t('admin.accounts.media_attachments')
         %td
-          = link_to @account.media_attachments.count, admin_account_statuses_path(@account.id, { media: true })
+          = link_to number_to_human(@account.media_attachments.count), admin_account_statuses_path(@account.id, { media: true })
           = surround '(', ')' do
             = number_to_human_size @account.media_attachments.sum('file_file_size')
       %tr
@@ -120,11 +126,12 @@
       = link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:suspend, @account)
 
 - if !@account.local? && @account.hub_url.present?
-  %hr
+  %hr.spacer/
+
   %h3 OStatus
 
   .table-wrapper
-    %table.table
+    %table.table.inline-table
       %tbody
         %tr
           %th= t('admin.accounts.feed_url')
@@ -148,11 +155,12 @@
         = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' if can?(:unsubscribe, @account)
 
 - if !@account.local? && @account.inbox_url.present?
-  %hr
+  %hr.spacer/
+
   %h3 ActivityPub
 
   .table-wrapper
-    %table.table
+    %table.table.inline-table
       %tbody
         %tr
           %th= t('admin.accounts.inbox_url')
@@ -167,24 +175,15 @@
           %th= t('admin.accounts.followers_url')
           %td= link_to @account.followers_url, @account.followers_url
 
-%hr
-%h3= t('admin.accounts.moderation_notes')
+%hr.spacer/
+
+= render @moderation_notes
 
 = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
   = render 'shared/error_messages', object: @account_moderation_note
 
-  = f.input :content
+  = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
   = f.hidden_field :target_account_id
 
   .actions
-  = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
-
-.table-wrapper
-  %table.table
-    %thead
-      %tr
-        %th
-        %th= t('admin.account_moderation_notes.account')
-        %th= t('admin.account_moderation_notes.created_at')
-    %tbody
-      = render @moderation_notes
+    = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
diff --git a/app/views/admin/reports/_account.html.haml b/app/views/admin/reports/_account.html.haml
index 22b7a0861..9ac161c9c 100644
--- a/app/views/admin/reports/_account.html.haml
+++ b/app/views/admin/reports/_account.html.haml
@@ -15,5 +15,5 @@
           .account__avatar{ style: "background-image: url(#{account.avatar.url}); width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px" }
         %span.display-name
           %bdi
-            %strong.display-name__html.emojify= display_name(account)
+            %strong.display-name__html.emojify= display_name(account, custom_emojify: true)
           %span.display-name__account @#{account.acct}
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
index 137609539..9057e6048 100644
--- a/app/views/admin/reports/_status.html.haml
+++ b/app/views/admin/reports/_status.html.haml
@@ -7,7 +7,7 @@
         %p><
           %strong= Formatter.instance.format_spoiler(status)
 
-      = Formatter.instance.format(status)
+      = Formatter.instance.format(status, custom_emojify: true)
 
     - unless status.media_attachments.empty?
       - if status.media_attachments.first.video?
diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml
index 9747a92cf..704ce1dbb 100644
--- a/app/views/admin/statuses/index.html.haml
+++ b/app/views/admin/statuses/index.html.haml
@@ -1,10 +1,7 @@
 - content_for :page_title do
   = t('admin.statuses.title')
-
-.back-link
-  = link_to admin_account_path(@account.id) do
-    %i.fa.fa-chevron-left.fa-fw
-    = t('admin.statuses.back_to_account')
+  \-
+  = "@#{@account.acct}"
 
 .filters
   .filter-subset
@@ -12,33 +9,26 @@
     %ul
       %li= link_to t('admin.statuses.no_media'), admin_account_statuses_path(@account.id, current_params.merge(media: nil)), class: !params[:media] && 'selected'
       %li= link_to t('admin.statuses.with_media'), admin_account_statuses_path(@account.id, current_params.merge(media: true)), class: params[:media] && 'selected'
+  .back-link{ style: 'flex: 1 1 auto; text-align: right' }
+    = link_to admin_account_path(@account.id) do
+      %i.fa.fa-chevron-left.fa-fw
+      = t('admin.statuses.back_to_account')
+
+%hr.spacer/
+
+= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f|
+  = hidden_field_tag :page, params[:page]
+  = hidden_field_tag :media, params[:media]
 
-- if @statuses.empty?
-  .accounts-grid
-    = render 'accounts/nothing_here'
-- else
-  = form_for(@form, url: admin_account_statuses_path(@account.id)) do |f|
-    = hidden_field_tag :page, params[:page]
-    = hidden_field_tag :media, params[:media]
-    .batch-form-box
-      .batch-checkbox-all
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
         = check_box_tag :batch_checkbox_all, nil, false
-      = f.select :action, Form::StatusBatch::ACTION_TYPE.map{|action| [t("admin.statuses.batch.#{action}"), action]}
-      = f.submit t('admin.statuses.execute'), data: { confirm: t('admin.reports.are_you_sure') }, class: 'button'
-      .media-spoiler-toggle-buttons
-        .media-spoiler-show-button.button= t('admin.statuses.media.show')
-        .media-spoiler-hide-button.button= t('admin.statuses.media.hide')
-    - @statuses.each do |status|
-      .account-status{ data: { id: status.id } }
-        .batch-checkbox
-          = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
-        .activity-stream.activity-stream-headless
-          .entry= render 'stream_entries/simple_status', status: status
-        .account-status__actions
-          - unless status.media_attachments.empty?
-            = link_to admin_account_status_path(@account.id, status, current_params.merge(status: { sensitive: !status.sensitive })), method: :patch, class: 'icon-button nsfw-button', title: t("admin.reports.nsfw.#{!status.sensitive}") do
-              = fa_icon status.sensitive? ? 'eye' : 'eye-slash'
-          = link_to admin_account_status_path(@account.id, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') }, remote: true do
-            = fa_icon 'trash'
+      .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
 
 = paginate @statuses
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index 2b07c923b..1af3193ae 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -2,9 +2,12 @@
   = t('auth.login')
 
 = simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
-  = f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, required: true, autofocus: true, hint: t('simple_form.hints.sessions.otp')
+  %p.hint{ style: 'margin-bottom: 25px' }= t('simple_form.hints.sessions.otp')
+
+  = f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, required: true, autofocus: true
 
   .actions
     = f.button :button, t('auth.login'), type: :submit
 
-.form-footer= render 'auth/shared/links'
+  - if Setting.site_contact_email.present?
+    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
diff --git a/app/views/authorize_follows/_card.html.haml b/app/views/authorize_follows/_card.html.haml
index e81e292ba..9abcfd37e 100644
--- a/app/views/authorize_follows/_card.html.haml
+++ b/app/views/authorize_follows/_card.html.haml
@@ -6,7 +6,7 @@
     %span.display-name
       - account_url = local_assigns[:admin] ? admin_account_path(account.id) : TagManager.instance.url_for(account)
       = link_to account_url, class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
-        %strong.emojify= display_name(account)
+        %strong.emojify= display_name(account, custom_emojify: true)
         %span @#{account.acct}
 
   - if account.note?
diff --git a/app/views/remote_unfollows/_card.html.haml b/app/views/remote_unfollows/_card.html.haml
index e81e292ba..9abcfd37e 100644
--- a/app/views/remote_unfollows/_card.html.haml
+++ b/app/views/remote_unfollows/_card.html.haml
@@ -6,7 +6,7 @@
     %span.display-name
       - account_url = local_assigns[:admin] ? admin_account_path(account.id) : TagManager.instance.url_for(account)
       = link_to account_url, class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
-        %strong.emojify= display_name(account)
+        %strong.emojify= display_name(account, custom_emojify: true)
         %span @#{account.acct}
 
   - if account.note?
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index 89d768d3f..30cd26914 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -10,15 +10,15 @@
         %td
       %tr
         %th= t('exports.follows')
-        %td= @export.total_follows
+        %td= number_to_human @export.total_follows
         %td= table_link_to 'download', t('exports.csv'), settings_exports_follows_path(format: :csv)
       %tr
         %th= t('exports.blocks')
-        %td= @export.total_blocks
+        %td= number_to_human @export.total_blocks
         %td= table_link_to 'download', t('exports.csv'), settings_exports_blocks_path(format: :csv)
       %tr
         %th= t('exports.mutes')
-        %td= @export.total_mutes
+        %td= number_to_human @export.total_mutes
         %td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
 
 %p.muted-hint= t('exports.archive_takeout.hint_html')
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 5f63466d9..d65a7f36f 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -20,6 +20,9 @@
     = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked')
 
   .fields-group
+    = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
+
+  .fields-group
     .input.with_block_label
       %label= t('simple_form.labels.defaults.fields')
       %span.hint= t('simple_form.hints.defaults.fields')
diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml
index ae26fc1ff..78f5ed4bc 100644
--- a/app/views/shared/_landing_strip.html.haml
+++ b/app/views/shared/_landing_strip.html.haml
@@ -2,7 +2,7 @@
   = image_tag asset_pack_path('logo.svg'), class: 'logo'
 
   %div
-    = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
+    = t('landing_strip_html', name: content_tag(:span, display_name(account, custom_emojify: true), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
 
     - if open_registrations?
       = t('landing_strip_signup_html', sign_up_path: new_user_registration_path)
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index afc66d148..c0f1e4f0f 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -4,7 +4,7 @@
       .avatar
         = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '', class: 'u-photo'
     %span.display-name
-      %strong.p-name.emojify= display_name(status.account)
+      %strong.p-name.emojify= display_name(status.account, custom_emojify: true)
       %span= acct(status.account)
 
   - if embedded_view?
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index cc2b6abe8..990e45094 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -10,7 +10,7 @@
         %div
           = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '', class: 'u-photo'
       %span.display-name
-        %strong.p-name.emojify= display_name(status.account)
+        %strong.p-name.emojify= display_name(status.account, custom_emojify: true)
         %span= acct(status.account)
 
   .status__content.p-name.emojify<
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index 9764bc74d..b87ca2177 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -28,7 +28,7 @@
         = fa_icon('retweet fw')
       %span
         = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do
-          %strong.emojify= display_name(status.account)
+          %strong.emojify= display_name(status.account, custom_emojify: true)
         = t('stream_entries.reblogged')
   - elsif pinned
     .pre-header
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 000aa0c4d..c2202a9eb 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -33,3 +33,5 @@
               %p= t 'about.about_mastodon_html'
 
               = render 'features'
+
+#modal-container