about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.circleci/config.yml2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/controllers/application_controller.rb1
-rw-r--r--app/javascript/mastodon/components/status.js9
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_mart_search_light.js22
-rw-r--r--app/javascript/mastodon/features/status/components/card.js18
-rw-r--r--app/javascript/mastodon/features/status/containers/card_container.js2
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/statuses.js3
-rw-r--r--app/javascript/styles/mastodon/components.scss29
-rw-r--r--app/models/media_attachment.rb6
-rw-r--r--app/models/status.rb10
-rw-r--r--app/models/status_stat.rb8
-rw-r--r--app/serializers/rest/status_serializer.rb2
-rw-r--r--app/services/fetch_link_card_service.rb1
-rw-r--r--config/locales/activerecord.ast.yml1
-rw-r--r--config/locales/ast.yml3
-rw-r--r--config/locales/cs.yml52
-rw-r--r--config/locales/cy.yml24
-rw-r--r--config/locales/devise.ast.yml1
-rw-r--r--config/locales/devise.cs.yml4
-rw-r--r--config/locales/devise.cy.yml4
-rw-r--r--config/locales/devise.hr.yml4
-rw-r--r--config/locales/devise.pl.yml4
-rw-r--r--config/locales/devise.zh-TW.yml4
-rw-r--r--config/locales/doorkeeper.ast.yml1
-rw-r--r--config/locales/en_GB.yml1
-rw-r--r--config/locales/hr.yml12
-rw-r--r--config/locales/pl.yml13
-rw-r--r--config/locales/simple_form.en_GB.yml1
-rw-r--r--config/locales/sk.yml20
-rw-r--r--config/locales/sr.yml28
-rw-r--r--config/locales/uk.yml24
-rw-r--r--config/locales/zh-CN.yml7
-rw-r--r--config/locales/zh-TW.yml49
-rw-r--r--db/migrate/20181024224956_migrate_account_conversations.rb50
-rw-r--r--lib/cli.rb4
-rw-r--r--lib/mastodon/accounts_cli.rb45
-rw-r--r--lib/mastodon/domains_cli.rb40
-rw-r--r--lib/mastodon/emoji_cli.rb1
-rw-r--r--lib/mastodon/feeds_cli.rb3
-rw-r--r--lib/mastodon/media_cli.rb1
-rw-r--r--lib/mastodon/settings_cli.rb1
44 files changed, 267 insertions, 256 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 4d2ebf00a..e968e8a07 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -178,7 +178,7 @@ jobs:
       - *attach_workspace
       - run: bundle exec i18n-tasks check-normalized
       - run: bundle exec i18n-tasks unused
-      - run: bundle exec i18n-tasks missing-plural-keys
+      - run: bundle exec i18n-tasks missing -t plural
       - run: bundle exec i18n-tasks check-consistent-interpolations
 
 workflows:
diff --git a/Gemfile b/Gemfile
index 71d54cede..5e44c6964 100644
--- a/Gemfile
+++ b/Gemfile
@@ -96,7 +96,7 @@ gem 'rdf-normalize', '~> 0.3'
 group :development, :test do
   gem 'fabrication', '~> 2.20'
   gem 'fuubar', '~> 2.3'
-  gem 'i18n-tasks', '~> 0.9', require: false, git: 'https://github.com/Gargron/i18n-tasks.git', ref: '7a57fbe7000f4f8120e250a757ab345c28c6885c'
+  gem 'i18n-tasks', '~> 0.9', require: false, git: 'https://github.com/Gargron/i18n-tasks.git', ref: 'ab6e10878ccdb6243f934f30372276d260c14251'
   gem 'pry-byebug', '~> 3.6'
   gem 'pry-rails', '~> 0.3'
   gem 'rspec-rails', '~> 3.8'
diff --git a/Gemfile.lock b/Gemfile.lock
index 36115dff1..30de668bb 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,7 @@
 GIT
   remote: https://github.com/Gargron/i18n-tasks.git
-  revision: 7a57fbe7000f4f8120e250a757ab345c28c6885c
-  ref: 7a57fbe7000f4f8120e250a757ab345c28c6885c
+  revision: ab6e10878ccdb6243f934f30372276d260c14251
+  ref: ab6e10878ccdb6243f934f30372276d260c14251
   specs:
     i18n-tasks (0.9.27)
       activesupport (>= 4.0.2)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index dca6c5a5a..983b116c9 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -201,6 +201,7 @@ class ApplicationController < ActionController::Base
   def respond_with_error(code)
     respond_to do |format|
       format.any  { head code }
+
       format.html do
         set_locale
         use_pack 'error'
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 0b23e51f8..9fa8cc008 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -9,6 +9,7 @@ import DisplayName from './display_name';
 import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import AttachmentList from './attachment_list';
+import Card from '../features/status/components/card';
 import { injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { MediaGallery, Video } from '../features/ui/util/async-components';
@@ -256,6 +257,14 @@ class Status extends ImmutablePureComponent {
           </Bundle>
         );
       }
+    } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
+      media = (
+        <Card
+          onOpenMedia={this.props.onOpenMedia}
+          card={status.get('card')}
+          compact
+        />
+      );
     }
 
     if (otherAccounts) {
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
index 36351ec02..164fdcc0b 100644
--- a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
@@ -2,7 +2,7 @@
 // https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
 
 import data from './emoji_mart_data_light';
-import { getData, getSanitizedData, intersect } from './emoji_utils';
+import { getData, getSanitizedData, uniq, intersect } from './emoji_utils';
 
 let originalPool = {};
 let index = {};
@@ -103,7 +103,7 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo
       }
     }
 
-    allResults = values.map((value) => {
+    const searchValue = (value) => {
       let aPool = pool,
         aIndex = index,
         length = 0;
@@ -150,15 +150,23 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo
       }
 
       return aIndex.results;
-    }).filter(a => a);
+    };
 
-    if (allResults.length > 1) {
-      results = intersect.apply(null, allResults);
-    } else if (allResults.length) {
-      results = allResults[0];
+    if (values.length > 1) {
+      results = searchValue(value);
     } else {
       results = [];
     }
+
+    allResults = values.map(searchValue).filter(a => a);
+
+    if (allResults.length > 1) {
+      allResults = intersect.apply(null, allResults);
+    } else if (allResults.length) {
+      allResults = allResults[0];
+    }
+
+    results = uniq(results.concat(allResults));
   }
 
   if (results) {
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index b52f3c4fa..9a87f7a3f 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -59,10 +59,12 @@ export default class Card extends React.PureComponent {
     card: ImmutablePropTypes.map,
     maxDescription: PropTypes.number,
     onOpenMedia: PropTypes.func.isRequired,
+    compact: PropTypes.boolean,
   };
 
   static defaultProps = {
     maxDescription: 50,
+    compact: false,
   };
 
   state = {
@@ -131,25 +133,25 @@ export default class Card extends React.PureComponent {
   }
 
   render () {
-    const { card, maxDescription } = this.props;
-    const { width, embedded }      = this.state;
+    const { card, maxDescription, compact } = this.props;
+    const { width, embedded } = this.state;
 
     if (card === null) {
       return null;
     }
 
     const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
-    const horizontal  = card.get('width') > card.get('height') && (card.get('width') + 100 >= width) || card.get('type') !== 'link';
-    const className   = classnames('status-card', { horizontal });
+    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
     const interactive = card.get('type') !== 'link';
+    const className   = classnames('status-card', { horizontal, compact, interactive });
     const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
-    const ratio       = card.get('width') / card.get('height');
+    const ratio       = compact ? 16 / 9 : card.get('width') / card.get('height');
     const height      = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
 
     const description = (
       <div className='status-card__content'>
         {title}
-        {!horizontal && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
+        {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
       </div>
     );
@@ -174,7 +176,7 @@ export default class Card extends React.PureComponent {
             <div className='status-card__actions'>
               <div>
                 <button onClick={this.handleEmbedClick}><i className={`fa fa-${iconVariant}`} /></button>
-                <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>
+                {horizontal && <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>}
               </div>
             </div>
           </div>
@@ -184,7 +186,7 @@ export default class Card extends React.PureComponent {
       return (
         <div className={className} ref={this.setRef}>
           {embed}
-          {description}
+          {!compact && description}
         </div>
       );
     } else if (card.get('image')) {
diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js
index a97404de1..6170d9fd8 100644
--- a/app/javascript/mastodon/features/status/containers/card_container.js
+++ b/app/javascript/mastodon/features/status/containers/card_container.js
@@ -2,7 +2,7 @@ import { connect } from 'react-redux';
 import Card from '../components/card';
 
 const mapStateToProps = (state, { statusId }) => ({
-  card: state.getIn(['cards', statusId], null),
+  card: state.getIn(['statuses', statusId, 'card'], null),
 });
 
 export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index e98566e26..2c98af1db 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -14,7 +14,6 @@ import relationships from './relationships';
 import settings from './settings';
 import push_notifications from './push_notifications';
 import status_lists from './status_lists';
-import cards from './cards';
 import mutes from './mutes';
 import reports from './reports';
 import contexts from './contexts';
@@ -46,7 +45,6 @@ const reducers = {
   relationships,
   settings,
   push_notifications,
-  cards,
   mutes,
   reports,
   contexts,
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 6e3d830da..2c58969f3 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -10,6 +10,7 @@ import {
   STATUS_REVEAL,
   STATUS_HIDE,
 } from '../actions/statuses';
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
 import { Map as ImmutableMap, fromJS } from 'immutable';
@@ -65,6 +66,8 @@ export default function statuses(state = initialState, action) {
     });
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.setIn([action.id, 'card'], fromJS(action.card));
   default:
     return state;
   }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index f8eb37c58..f778ba06b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1669,6 +1669,7 @@ a.account__display-name {
   padding: 4px 0;
   border-radius: 4px;
   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  z-index: 9999;
 
   ul {
     list-style: none;
@@ -2560,6 +2561,9 @@ a.status-card {
   display: block;
   margin-top: 5px;
   font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .status-card__image {
@@ -2584,6 +2588,31 @@ a.status-card {
   }
 }
 
+.status-card.compact {
+  border-color: lighten($ui-base-color, 4%);
+
+  &.interactive {
+    border: 0;
+  }
+
+  .status-card__content {
+    padding: 8px;
+    padding-top: 10px;
+  }
+
+  .status-card__title {
+    white-space: nowrap;
+  }
+
+  .status-card__image {
+    flex: 0 0 60px;
+  }
+}
+
+a.status-card.compact:hover {
+  background-color: lighten($ui-base-color, 4%);
+}
+
 .status-card__image-image {
   border-radius: 4px 0 0 4px;
   display: block;
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 3e3cbdaed..0f787ebc4 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -148,6 +148,7 @@ class MediaAttachment < ApplicationRecord
     "#{x},#{y}"
   end
 
+  after_commit :reset_parent_cache, on: :update
   before_create :prepare_description, unless: :local?
   before_create :set_shortcode
   before_post_process :set_type_and_extension
@@ -252,4 +253,9 @@ class MediaAttachment < ApplicationRecord
       bitrate: movie.bitrate,
     }
   end
+
+  def reset_parent_cache
+    return if status_id.nil?
+    Rails.cache.delete("statuses/#{status_id}")
+  end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 438863589..f67a05b3c 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -94,6 +94,7 @@ class Status < ApplicationRecord
                    :conversation,
                    :status_stat,
                    :tags,
+                   :preview_cards,
                    :stream_entry,
                    active_mentions: :account,
                    reblog: [
@@ -101,6 +102,7 @@ class Status < ApplicationRecord
                      :application,
                      :stream_entry,
                      :tags,
+                     :preview_cards,
                      :media_attachments,
                      :conversation,
                      :status_stat,
@@ -168,6 +170,10 @@ class Status < ApplicationRecord
     reblog
   end
 
+  def preview_card
+    preview_cards.first
+  end
+
   def title
     if destroyed?
       "#{account.acct} deleted status"
@@ -241,10 +247,6 @@ class Status < ApplicationRecord
   before_validation :set_local
 
   class << self
-    def cache_ids
-      left_outer_joins(:status_stat).select('statuses.id, greatest(statuses.updated_at, status_stats.updated_at) AS updated_at')
-    end
-
     def selectable_visibilities
       visibilities.keys - %w(direct limited)
     end
diff --git a/app/models/status_stat.rb b/app/models/status_stat.rb
index 9d358776b..024c467e7 100644
--- a/app/models/status_stat.rb
+++ b/app/models/status_stat.rb
@@ -14,4 +14,12 @@
 
 class StatusStat < ApplicationRecord
   belongs_to :status, inverse_of: :status_stat
+
+  after_commit :reset_parent_cache
+
+  private
+
+  def reset_parent_cache
+    Rails.cache.delete("statuses/#{status_id}")
+  end
 end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 07839c5ae..8a61c1056 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -21,6 +21,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
   has_many :tags
   has_many :emojis, serializer: REST::CustomEmojiSerializer
 
+  has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
+
   def id
     object.id.to_s
   end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 4551aa7e0..462e5ee13 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -63,6 +63,7 @@ class FetchLinkCardService < BaseService
 
   def attach_card
     @status.preview_cards << @card
+    Rails.cache.delete(@status)
   end
 
   def parse_urls
diff --git a/config/locales/activerecord.ast.yml b/config/locales/activerecord.ast.yml
index 0d161faf2..6e32cbc2f 100644
--- a/config/locales/activerecord.ast.yml
+++ b/config/locales/activerecord.ast.yml
@@ -1 +1,2 @@
+---
 ast: {}
diff --git a/config/locales/ast.yml b/config/locales/ast.yml
index 98986cdd0..f787e98f8 100644
--- a/config/locales/ast.yml
+++ b/config/locales/ast.yml
@@ -21,8 +21,7 @@ ast:
     hosted_on: Mastodon ta agospiáu en %{domain}
     learn_more: Deprendi más
     source_code: Códigu fonte
-    status_count_after:
-      other: estaos
+    status_count_after: estaos
     terms: Términos del serviciu
     user_count_after: usuarios
     what_is_mastodon: "¿Qué ye Mastodon?"
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index 67bda70f1..c73c4fee1 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -30,22 +30,16 @@ cs:
     other_instances: Seznam instancí
     privacy_policy: Zásady soukromí
     source_code: Zdrojový kód
-    status_count_after:
-      one: příspěvek
-      other: příspěvků
+    status_count_after: příspěvků
     status_count_before: Kteří napsali
     terms: Podmínky používání
-    user_count_after:
-      one: uživatele
-      other: uživatelů
+    user_count_after: uživatelů
     user_count_before: Domov
     what_is_mastodon: Co je Mastodon?
   accounts:
     choices_html: 'Volby uživatele %{name}:'
     follow: Sledovat
-    followers:
-      one: Sledovatel
-      other: Sledovatelé
+    followers: Sledovatelé
     following: Sledovaní
     joined: Připojil/a se v %{date}
     link_verified_on: Vlastnictví tohoto odkazu bylo zkontrolováno %{date}
@@ -57,9 +51,7 @@ cs:
     people_who_follow: Lidé, kteří sledují uživatele %{name}
     pin_errors:
       following: Musíte již sledovat osobu, kterou chcete podpořit
-    posts:
-      one: Toot
-      other: Tooty
+    posts: Tooty
     posts_tab_heading: Tooty
     posts_with_replies: Tooty a odpovědi
     reserved_username: Toto uživatelské jméno je rezervováno
@@ -268,9 +260,7 @@ cs:
         suspend: Suspendovat
       severity: Přísnost
       show:
-        affected_accounts:
-          one: Jeden účet v databázi byl ovlivněn
-          other: "%{count} účtů v databázi byl ovlivněn"
+        affected_accounts: "%{count} účtů v databázi byl ovlivněn"
         retroactive:
           silence: Odtišit všechny existující účty z této domény
           suspend: Zrušit suspenzaci všech existujících účtů z této domény
@@ -562,9 +552,7 @@ cs:
     followers_count: Počet sledovatelů
     lock_link: Zamkněte svůj účet
     purge: Odstranit ze sledovatelů
-    success:
-      one: V průběhu utišování sledovatelů z jedné domény...
-      other: V průběhu utišování sledovatelů z %{count} domén...
+    success: V průběhu utišování sledovatelů z %{count} domén...
     true_privacy_html: Berte prosím na vědomí, že <strong>skutečného soukromí se dá dosáhnout pouze za pomoci end-to-end šifrování</strong>.
     unlocked_warning_html: Kdokoliv vás může sledovat a okamžitě vidět vaše soukromé příspěvky. %{lock_link}, abyste mohl/a zkontrolovat a odmítnout sledovatele.
     unlocked_warning_title: Váš účet není zamknutý
@@ -575,9 +563,7 @@ cs:
   generic:
     changes_saved_msg: Změny byly úspěšně uloženy!
     save_changes: Uložit změny
-    validation_errors:
-      one: Něco ještě není úplně v pořádku! Prosím zkontrolujte chybu níže
-      other: Něco ještě není úplně v pořádku! Prosím zkontrolujte %{count} chyb níže
+    validation_errors: Něco ještě není úplně v pořádku! Prosím zkontrolujte %{count} chyb níže
   imports:
     preface: Můžete importovat data, která jste exportoval/a z jiné instance, jako například seznam lidí, které sledujete či blokujete.
     success: Vaše data byla úspěšně nahrána a nyní budou zpracována v daný čas
@@ -600,9 +586,7 @@ cs:
     expires_in_prompt: Nikdy
     generate: Vygenerovat
     invited_by: 'Byl/a jste pozván/a uživatelem:'
-    max_uses:
-      one: 1 použití
-      other: "%{count} použití"
+    max_uses: "%{count} použití"
     max_uses_prompt: Bez limitu
     prompt: Vygenerujte a sdílejte s ostatními odkazy a umožněte jim přístup na tuto instanci
     table:
@@ -628,12 +612,8 @@ cs:
       action: Zobrazit všechna oznámení
       body: Zde najdete stručný souhrn zpráv, které jste zmeškal/a od vaší poslední návštěvy %{since}
       mention: "%{name} vás zmínil/a v:"
-      new_followers_summary:
-        one: Navíc jste získal/a jednoho nového sledovatele, zatímco jste byl/a pryč! Hurá!
-        other: Navíc jste získal/a %{count} nových sledovatelů, zatímco jste byl/a pryč! Hurá!
-      subject:
-        one: "Jedno nové oznámení od vaší poslední návštěvy \U0001F418"
-        other: "%{count} nových oznámení od vaší poslední návštěvy \U0001F418"
+      new_followers_summary: Navíc jste získal/a %{count} nových sledovatelů, zatímco jste byl/a pryč! Hurá!
+      subject: "%{count} nových oznámení od vaší poslední návštěvy \U0001F418"
       title: Ve vaší absenci...
     favourite:
       body: 'Váš příspěvek si oblíbil/a %{name}:'
@@ -750,17 +730,11 @@ cs:
   statuses:
     attached:
       description: 'Přiloženo: %{attached}'
-      image:
-        one: "%{count} obrázek"
-        other: "%{count} obrázků"
-      video:
-        one: "%{count} video"
-        other: "%{count} videí"
+      image: "%{count} obrázků"
+      video: "%{count} videí"
     boosted_from_html: Boostnuto z %{acct_link}
     content_warning: 'Varování o obsahu: %{warning}'
-    disallowed_hashtags:
-      one: 'obsahuje nepovolený hashtag: %{tags}'
-      other: 'obsahuje nepovolené hashtagy: %{tags}'
+    disallowed_hashtags: 'obsahuje nepovolené hashtagy: %{tags}'
     language_detection: Zjistit jazyk automaticky
     open_in_web: Otevřít na webu
     over_character_limit: limit %{max} znaků byl překročen
diff --git a/config/locales/cy.yml b/config/locales/cy.yml
index 8b16949a5..3bf256ac5 100644
--- a/config/locales/cy.yml
+++ b/config/locales/cy.yml
@@ -30,22 +30,16 @@ cy:
     other_instances: Rhestr achosion
     privacy_policy: Polisi preifatrwydd
     source_code: Cod ffynhonnell
-    status_count_after:
-      one: statws
-      other: statws
+    status_count_after: statws
     status_count_before: Pwy ysgrifennodd
     terms: Telerau gwasanaeth
-    user_count_after:
-      one: defnyddiwr
-      other: defnyddwyr
+    user_count_after: defnyddwyr
     user_count_before: Cartref i
     what_is_mastodon: Beth yw Mastodon?
   accounts:
     choices_html: 'Dewisiadau %{name}:'
     follow: Dilynwch
-    followers:
-      one: Dilynwr
-      other: Dilynwyr
+    followers: Dilynwyr
     following: Yn dilyn
     joined: Ymunodd %{date}
     media: Cyfryngau
@@ -56,9 +50,7 @@ cy:
     people_who_follow: Pobl sy'n dilyn %{name}
     pin_errors:
       following: Rhaid i ti fod yn dilyn y person yr ydych am ei gymeradwyo yn barod
-    posts:
-      one: Tŵt
-      other: Tŵtiau
+    posts: Tŵtiau
     posts_tab_heading: Tŵtiau
     posts_with_replies: Tŵtiau ac atebion
     reserved_username: Mae'r enw defnyddior yn neilltuedig
@@ -262,9 +254,7 @@ cy:
         suspend: Atal
       severity: Difrifoldeb
       show:
-        affected_accounts:
-          one: Mae un cyfri yn y bas data wedi ei effeithio
-          other: "%{count} o gyfrifoedd yn y bas data wedi eu hefeithio"
+        affected_accounts: "%{count} o gyfrifoedd yn y bas data wedi eu hefeithio"
         retroactive:
           silence: Dad-dawelu pob cyfri presennol o'r parth hwn
           suspend: Dad-atal pob cyfrif o'r parth hwn sy'n bodoli
@@ -508,9 +498,7 @@ cy:
   generic:
     changes_saved_msg: Llwyddwyd i gadw y newidiadau!
     save_changes: Cadw newidiadau
-    validation_errors:
-      one: Mae rhywbeth o'i le o hyd! Edrychwch ar y gwall isod os gwelwch yn dda
-      other: Mae rhywbeth o'i le o hyd! Edrychwch ar y %{count} gwall isod os gwelwch yn dda
+    validation_errors: Mae rhywbeth o'i le o hyd! Edrychwch ar y %{count} gwall isod os gwelwch yn dda
   imports:
     preface: Mae modd mewnforio data yr ydych wedi allforio o achos arall, megis rhestr o bobl yr ydych yn ei ddilyn neu yn blocio.
     success: Uwchlwyddwyd eich data yn llwyddiannus ac fe fydd yn cael ei brosesu mewn da bryd
diff --git a/config/locales/devise.ast.yml b/config/locales/devise.ast.yml
index 0d161faf2..6e32cbc2f 100644
--- a/config/locales/devise.ast.yml
+++ b/config/locales/devise.ast.yml
@@ -1 +1,2 @@
+---
 ast: {}
diff --git a/config/locales/devise.cs.yml b/config/locales/devise.cs.yml
index 49814b368..4268dc0ad 100644
--- a/config/locales/devise.cs.yml
+++ b/config/locales/devise.cs.yml
@@ -77,6 +77,4 @@ cs:
       expired: vypršel, prosím vyžádejte si nový
       not_found: nenalezen
       not_locked: nebyl uzamčen
-      not_saved:
-        one: '1 chyba zabránila uložení tohoto %{resource}:'
-        other: "%{count} chyb zabránila uložení tohoto %{resource}:"
+      not_saved: "%{count} chyb zabránila uložení tohoto %{resource}:"
diff --git a/config/locales/devise.cy.yml b/config/locales/devise.cy.yml
index 9cf8b96f0..d7d694f14 100644
--- a/config/locales/devise.cy.yml
+++ b/config/locales/devise.cy.yml
@@ -77,6 +77,4 @@ cy:
       expired: wedi dod i ben, gwnewch gais am un newydd os gwelwch yn dda
       not_found: heb ei ganfod
       not_locked: heb ei gloi
-      not_saved:
-        one: 'Gwaharddwyd yr %{resource} rhag cael ei arbed oherwydd 1 gwall:'
-        other: 'Gwaharddwyd yr %{resource} rhag cael ei arbed oherwydd %{count} gwall:'
+      not_saved: 'Gwaharddwyd yr %{resource} rhag cael ei arbed oherwydd %{count} gwall:'
diff --git a/config/locales/devise.hr.yml b/config/locales/devise.hr.yml
index 07c0079ab..276d26cad 100644
--- a/config/locales/devise.hr.yml
+++ b/config/locales/devise.hr.yml
@@ -58,6 +58,4 @@ hr:
       expired: je istekao, zatraži novu
       not_found: nije nađen
       not_locked: nije zaključan
-      not_saved:
-        one: '1 greška je zabranila da ovaj %{resource} bude sačuvan:'
-        other: "%{count} greške su zabranile da ovaj %{resource} bude sačuvan:"
+      not_saved: "%{count} greške su zabranile da ovaj %{resource} bude sačuvan:"
diff --git a/config/locales/devise.pl.yml b/config/locales/devise.pl.yml
index 77afc4bf5..54bae2925 100644
--- a/config/locales/devise.pl.yml
+++ b/config/locales/devise.pl.yml
@@ -77,6 +77,4 @@ pl:
       expired: wygasło, poproś o nowe
       not_found: nie znaleziono
       not_locked: było zablokowane
-      not_saved:
-        one: '1 błąd uniemożliwił zapisanie zasobu %{resource}:'
-        other: 'Błędy (%{count}) uniemożliwiły zapisanie zasobu %{resource}:'
+      not_saved: 'Błędy (%{count}) uniemożliwiły zapisanie zasobu %{resource}:'
diff --git a/config/locales/devise.zh-TW.yml b/config/locales/devise.zh-TW.yml
index 195f167a0..6dec562e1 100644
--- a/config/locales/devise.zh-TW.yml
+++ b/config/locales/devise.zh-TW.yml
@@ -77,6 +77,4 @@ zh-TW:
       expired: 已經過期,請重新申請
       not_found: 找不到
       not_locked: 並未被鎖定
-      not_saved:
-        one: 1 個錯誤使 %{resource} 無法被儲存︰
-        other: "%{count} 個錯誤使 %{resource} 無法被儲存︰"
+      not_saved: "%{count} 個錯誤使 %{resource} 無法被儲存︰"
diff --git a/config/locales/doorkeeper.ast.yml b/config/locales/doorkeeper.ast.yml
index 0d161faf2..6e32cbc2f 100644
--- a/config/locales/doorkeeper.ast.yml
+++ b/config/locales/doorkeeper.ast.yml
@@ -1 +1,2 @@
+---
 ast: {}
diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml
index 0967ef424..d9e1a256f 100644
--- a/config/locales/en_GB.yml
+++ b/config/locales/en_GB.yml
@@ -1 +1,2 @@
+---
 {}
diff --git a/config/locales/hr.yml b/config/locales/hr.yml
index 851b3623b..729206a98 100644
--- a/config/locales/hr.yml
+++ b/config/locales/hr.yml
@@ -61,9 +61,7 @@ hr:
   generic:
     changes_saved_msg: Izmjene su uspješno sačuvane!
     save_changes: Sačuvaj izmjene
-    validation_errors:
-      one: Nešto ne štima! Vidi grešku ispod
-      other: Nešto još uvijek ne štima! Vidi %{count} greške ispod
+    validation_errors: Nešto još uvijek ne štima! Vidi %{count} greške ispod
   imports:
     preface: Možeš uvesti određene podatke kao što su svi ljudi koje slijediš ili blokiraš u svoj račun na ovoj instanci, sa fajlova kreiranih izvozom sa druge instance.
     success: Tvoji podaci su uspješno uploadani i bit će obrađeni u dogledno vrijeme
@@ -76,12 +74,8 @@ hr:
     digest:
       body: 'Ovo je kratak sažetak propuštenog od tvog prošlog posjeta %{since}:'
       mention: "%{name} te je spomenuo:"
-      new_followers_summary:
-        one: Imaš novog sljedbenika! Yay!
-        other: Imaš %{count} novih sljedbenika! Prekrašno!
-      subject:
-        one: "1 nova notifikacija od tvog prošlog posjeta \U0001F418"
-        other: "%{count} novih notifikacija od tvog prošlog posjeta \U0001F418"
+      new_followers_summary: Imaš %{count} novih sljedbenika! Prekrašno!
+      subject: "%{count} novih notifikacija od tvog prošlog posjeta \U0001F418"
     favourite:
       body: 'Tvoj status je %{name} označio kao omiljen:'
       subject: "%{name} je označio kao omiljen tvoj status"
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index b8ba96ba8..542f66c4f 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -279,10 +279,7 @@ pl:
         suspend: Zawieś
       severity: Priorytet
       show:
-        affected_accounts:
-          many: Dotyczy %{count} kont w bazie danych
-          one: Dotyczy jednego konta w bazie danych
-          other: Dotyczy %{count} kont w bazie danych
+        affected_accounts: Dotyczy %{count} kont w bazie danych
         retroactive:
           silence: Odwołaj wyciszenie wszystkich kont w tej domenie
           suspend: Odwołaj zawieszenie wszystkich kont w tej domenie
@@ -577,9 +574,7 @@ pl:
     followers_count: Liczba śledzących
     lock_link: Zablokuj swoje konto
     purge: Przestań śledzić
-    success:
-      one: W trakcie usuwania śledzących z jednej domeny…
-      other: W trakcie usuwania śledzących z %{count} domen…
+    success: W trakcie usuwania śledzących z %{count} domen…
     true_privacy_html: Pamiętaj, że <strong>rzeczywista prywatność może zostać uzyskana wyłącznie dzięki szyfrowaniu end-to-end</strong>.
     unlocked_warning_html: Każdy może Cię śledzić, dzięki czemu może zobaczyć Twoje niepubliczne wpisy. %{lock_link} aby móc kontrolować, kto Cię śledzi.
     unlocked_warning_title: Twoje konto nie jest zablokowane
@@ -788,9 +783,7 @@ pl:
         other: "%{count} filmów"
     boosted_from_html: Podbito przez %{acct_link}
     content_warning: 'Ostrzeżenie o zawartości: %{warning}'
-    disallowed_hashtags:
-      one: 'zawiera niedozwolony hashtag: %{tags}'
-      other: 'zawiera niedozwolone hashtagi: %{tags}'
+    disallowed_hashtags: 'zawiera niedozwolone hashtagi: %{tags}'
     language_detection: Automatycznie wykrywaj język
     open_in_web: Otwórz w przeglądarce
     over_character_limit: limit %{max} znaków przekroczony
diff --git a/config/locales/simple_form.en_GB.yml b/config/locales/simple_form.en_GB.yml
index 0967ef424..d9e1a256f 100644
--- a/config/locales/simple_form.en_GB.yml
+++ b/config/locales/simple_form.en_GB.yml
@@ -1 +1,2 @@
+---
 {}
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index ba3d77a04..bf56ef465 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -30,22 +30,16 @@ sk:
     other_instances: Zoznam ďalších inštancií
     privacy_policy: Ustanovenia o súkromí
     source_code: Zdrojový kód
-    status_count_after:
-      one: status
-      other: statusy
+    status_count_after: statusy
     status_count_before: Ktorí napísali
     terms: Podmienky užívania
-    user_count_after:
-      one: užívateľ
-      other: užívateľov
+    user_count_after: užívateľov
     user_count_before: Domov pre
     what_is_mastodon: Čo je Mastodon?
   accounts:
     choices_html: "%{name}vé voľby:"
     follow: Sledovať
-    followers:
-      one: Následovateľ
-      other: Sledovatelia
+    followers: Sledovatelia
     following: Sledovaní
     joined: Pridal/a sa %{date}
     media: Médiá
@@ -56,9 +50,7 @@ sk:
     people_who_follow: Ľudia sledujúci %{name}
     pin_errors:
       following: Musíš už následovať toho človeka, ktorého si praješ zviditeľniť
-    posts:
-      one: Príspevok
-      other: Príspevky
+    posts: Príspevky
     posts_tab_heading: Príspevky
     posts_with_replies: Príspevky s odpoveďami
     reserved_username: Prihlasovacie meno je rezervované
@@ -746,9 +738,7 @@ sk:
         other: "%{count} videí"
     boosted_from_html: Povýšené od %{acct_link}
     content_warning: 'Varovanie o obsahu: %{warning}'
-    disallowed_hashtags:
-      one: 'obsahuje nepovolený haštag: %{tags}'
-      other: 'obsahuje nepovolené haštagy: %{tags}'
+    disallowed_hashtags: 'obsahuje nepovolené haštagy: %{tags}'
     language_detection: Zisti jazyk automaticky
     open_in_web: Otvor v okne prehliadača
     over_character_limit: limit počtu %{max} znakov bol presiahnutý
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index 36d81886e..1ade87f9e 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -30,22 +30,16 @@ sr:
     other_instances: Листа инстанци
     privacy_policy: Полиса приватности
     source_code: Изворни код
-    status_count_after:
-      one: статус
-      other: статуси
+    status_count_after: статуси
     status_count_before: Који су написали
     terms: Услови коришћења
-    user_count_after:
-      one: корисник
-      other: корисници
+    user_count_after: корисници
     user_count_before: Дом за
     what_is_mastodon: Шта је Мастодон?
   accounts:
     choices_html: "%{name}'s избори:"
     follow: Запрати
-    followers:
-      one: Пратилац
-      other: Пратиоци
+    followers: Пратиоци
     following: Пратим
     joined: Придружио/ла се %{date}
     media: Медији
@@ -56,9 +50,7 @@ sr:
     people_who_follow: Људи који прате %{name}
     pin_errors:
       following: Морате пратити ову особу ако хоћете да потврдите
-    posts:
-      one: Труба
-      other: Трубе
+    posts: Трубе
     posts_tab_heading: Трубе
     posts_with_replies: Трубе и одговори
     reserved_username: Корисничко име је резервисано
@@ -754,17 +746,11 @@ sr:
   statuses:
     attached:
       description: 'У прилогу: %{attached}'
-      image:
-        one: "%{count} слику"
-        other: "%{count} слике"
-      video:
-        one: "%{count} видео"
-        other: "%{count} видеа"
+      image: "%{count} слике"
+      video: "%{count} видеа"
     boosted_from_html: Подржано од %{acct_link}
     content_warning: 'Упозорење на садржај: %{warning}'
-    disallowed_hashtags:
-      one: 'садржи забрањену тарабу: %{tags}'
-      other: 'садржи забрањене тарабе: %{tags}'
+    disallowed_hashtags: 'садржи забрањене тарабе: %{tags}'
     language_detection: Аутоматскo откривање језика
     open_in_web: Отвори у вебу
     over_character_limit: ограничење од %{max} карактера прекорачено
diff --git a/config/locales/uk.yml b/config/locales/uk.yml
index 83a315a37..986ef70ad 100644
--- a/config/locales/uk.yml
+++ b/config/locales/uk.yml
@@ -518,18 +518,14 @@ uk:
     followers_count: Кількість підписників
     lock_link: Закрийте акаунт
     purge: Видалити з підписників
-    success:
-      one: У процесі м'якого блокування підписників з одного домену...
-      other: У процесі м'якого блокування підписників з %{count} доменів...
+    success: У процесі м'якого блокування підписників з %{count} доменів...
     true_privacy_html: Будь ласка, помітьте, що <strong>справжняя конфіденційність може бути досягнена тільки за допомогою end-to-end шифрування</strong>.
     unlocked_warning_html: Хто завгодно може підписатися на Вас та отримати доступ до перегляду Ваших приватних статусів. %{lock_link}, щоб отримати можливість роздивлятися та вручну підтверджувати запити щодо підписки.
     unlocked_warning_title: Ваш аккаунт не закритий для підписки
   generic:
     changes_saved_msg: Зміни успішно збережені!
     save_changes: Зберегти зміни
-    validation_errors:
-      one: Щось тут не так! Будь ласка, ознайомтеся з помилкою нижче
-      other: Щось тут не так! Будь ласка, ознайомтеся з %{count} помилками нижче
+    validation_errors: Щось тут не так! Будь ласка, ознайомтеся з %{count} помилками нижче
   imports:
     preface: Вы можете завантажити деякі дані, наприклад, списки людей, на яких Ви підписані чи яких блокуєте, в Ваш акаунт на цій інстанції з файлів, експортованих з іншої інстанції.
     success: Ваші дані були успішно загружені та будуть оброблені в найближчий момент
@@ -552,9 +548,7 @@ uk:
     expires_in_prompt: Ніколи
     generate: Згенерувати
     invited_by: 'Вас запросив(-ла):'
-    max_uses:
-      one: 1 використання
-      other: "%{count} використань"
+    max_uses: "%{count} використань"
     max_uses_prompt: Без обмеження
     prompt: Генеруйте та діліться посиланням з іншими для надання доступу до сайту
     table:
@@ -703,17 +697,11 @@ uk:
   statuses:
     attached:
       description: 'Прикріплено: %{attached}'
-      image:
-        one: "%{count} картинка"
-        other: "%{count} картинки"
-      video:
-        one: "%{count} відео"
-        other: "%{count} відео"
+      image: "%{count} картинки"
+      video: "%{count} відео"
     boosted_from_html: Просунуто від %{acct_link}
     content_warning: 'Попередження про контент: %{warning}'
-    disallowed_hashtags:
-      one: 'містив заборонений хештеґ: %{tags}'
-      other: 'містив заборонені хештеґи: %{tags}'
+    disallowed_hashtags: 'містив заборонені хештеґи: %{tags}'
     language_detection: Автоматично визначати мову
     open_in_web: Відкрити у вебі
     over_character_limit: перевищено ліміт символів (%{max})
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 0ce1a0ed7..ca2d388b0 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -30,13 +30,10 @@ zh-CN:
     other_instances: 其他实例
     privacy_policy: 隐私政策
     source_code: 源代码
-    status_count_after:
-      one: 条嘟文
+    status_count_after: 条嘟文
     status_count_before: 他们共嘟出了
     terms: 使用条款
-    user_count_after:
-      one: 位用户
-      other: 位用户
+    user_count_after: 位用户
     user_count_before: 这里共注册有
     what_is_mastodon: Mastodon 是什么?
   accounts:
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index d1b7633f3..9a7c2b293 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -29,18 +29,15 @@ zh-TW:
     learn_more: 了解詳細
     other_instances: 其他站點
     source_code: 原始碼
-    status_count_after:
-      one: 狀態
+    status_count_after: 狀態
     status_count_before: 他們共嘟出了
     terms: 使用條款
-    user_count_after:
-      one: 使用者
+    user_count_after: 使用者
     user_count_before: 這裡共註冊有
     what_is_mastodon: 什麼是 Mastodon?
   accounts:
     follow: 關注
-    followers:
-      other: 關注者
+    followers: 關注者
     following: 正在關注
     media: 媒體
     moved_html: "%{name} 已經搬遷到 %{new_profile_link}:"
@@ -48,9 +45,7 @@ zh-TW:
     nothing_here: 暫時沒有內容可供顯示!
     people_followed_by: "%{name} 關注的人"
     people_who_follow: 關注 %{name} 的人
-    posts:
-      one: 嘟掉
-      other: 嘟文
+    posts: 嘟文
     posts_tab_heading: 嘟文
     posts_with_replies: 嘟文與回覆
     reserved_username: 此用戶名已被保留
@@ -234,9 +229,7 @@ zh-TW:
         suspend: 自動封鎖
       severity: 嚴重度
       show:
-        affected_accounts:
-          one: 資料庫中有一個使用者受到影響
-          other: 資料庫中有%{count}個使用者受影響
+        affected_accounts: 資料庫中有%{count}個使用者受影響
         retroactive:
           silence: 對此網域的所有使用者取消靜音
           suspend: 對此網域的所有使用者取消封鎖
@@ -480,18 +473,14 @@ zh-TW:
     followers_count: 關注者數量
     lock_link: 將你的帳戶設定為私人
     purge: 移除關注者
-    success:
-      one: 正準備軟性封鎖 1 個網域的關注者……
-      other: 正準備軟性封鎖 %{count} 個網域的關注者……
+    success: 正準備軟性封鎖 %{count} 個網域的關注者……
     true_privacy_html: 請謹記,唯有<strong>點對點加密方可以真正確保你的隱私</strong>。
     unlocked_warning_html: 任何人都可以在關注你後立即查看非公開的嘟文。只要%{lock_link},你就可以審核並拒絕關注請求。
     unlocked_warning_title: 你的帳戶是公開的
   generic:
     changes_saved_msg: 已成功儲存修改!
     save_changes: 儲存修改
-    validation_errors:
-      one: 送出的資料有問題
-      other: 送出的資料有 %{count} 個問題
+    validation_errors: 送出的資料有 %{count} 個問題
   imports:
     preface: 您可以在此匯入您在其他站點所匯出的資料檔,包括關注的使用者、封鎖的使用者名單。
     success: 資料檔上傳成功,正在匯入,請稍候
@@ -514,9 +503,7 @@ zh-TW:
     expires_in_prompt: 永不過期
     generate: 建立邀請連結
     invited_by: 你的邀請人是:
-    max_uses:
-      one: 1 次
-      other: "%{count} 次"
+    max_uses: "%{count} 次"
     max_uses_prompt: 無限制
     prompt: 建立分享連結,邀請他人在本站點註冊
     table:
@@ -542,12 +529,8 @@ zh-TW:
       action: 閱覽所有通知
       body: 以下是自%{since}你最後一次登入以來錯過的訊息摘要
       mention: "%{name} 在此提及了你:"
-      new_followers_summary:
-        one: 而且,你不在的時候,有一個人關注你! 耶!
-        other: 而且,你不在的時候,有 %{count} 個人關注你了! 好棒!
-      subject:
-        one: "自從上次登入以來,你收到 1 則新的通知 \U0001F418"
-        other: "自從上次登入以來,你收到 %{count} 則新的通知 \U0001F418"
+      new_followers_summary: 而且,你不在的時候,有 %{count} 個人關注你了! 好棒!
+      subject: "自從上次登入以來,你收到 %{count} 則新的通知 \U0001F418"
       title: 你不在的時候...
     favourite:
       body: '你的嘟文被 %{name} 加入了最愛:'
@@ -653,17 +636,11 @@ zh-TW:
   statuses:
     attached:
       description: 附件: %{attached}
-      image:
-        one: "%{count} 幅圖片"
-        other: "%{count} 幅圖片"
-      video:
-        one: "%{count} 段影片"
-        other: "%{count} 段影片"
+      image: "%{count} 幅圖片"
+      video: "%{count} 段影片"
     boosted_from_html: 轉嘟自 %{acct_link}
     content_warning: 內容警告: %{warning}
-    disallowed_hashtags:
-      one: 包含不允許的標籤: %{tags}
-      other: 包含不允許的標籤: %{tags}
+    disallowed_hashtags: 包含不允許的標籤: %{tags}
     language_detection: 自動偵測語言
     open_in_web: 以網頁開啟
     over_character_limit: 超過了 %{max} 字的限制
diff --git a/db/migrate/20181024224956_migrate_account_conversations.rb b/db/migrate/20181024224956_migrate_account_conversations.rb
index 1821e8c27..47f7375ba 100644
--- a/db/migrate/20181024224956_migrate_account_conversations.rb
+++ b/db/migrate/20181024224956_migrate_account_conversations.rb
@@ -14,12 +14,29 @@ class MigrateAccountConversations < ActiveRecord::Migration[5.2]
       sleep 1
     end
 
-    local_direct_statuses.find_each do |status|
+    total        = estimate_rows(local_direct_statuses) + estimate_rows(notifications_about_direct_statuses)
+    migrated     = 0
+    started_time = Time.zone.now
+    last_time    = Time.zone.now
+
+    local_direct_statuses.includes(:account, mentions: :account).find_each do |status|
       AccountConversation.add_status(status.account, status)
+      migrated += 1
+
+      if Time.zone.now - last_time > 1
+        say_progress(migrated, total, started_time)
+        last_time = Time.zone.now
+      end
     end
 
-    notifications_about_direct_statuses.find_each do |notification|
+    notifications_about_direct_statuses.includes(:account, mention: { status: [:account, mentions: :account] }).find_each do |notification|
       AccountConversation.add_status(notification.account, notification.target_status)
+      migrated += 1
+
+      if Time.zone.now - last_time > 1
+        say_progress(migrated, total, started_time)
+        last_time = Time.zone.now
+      end
     end
   end
 
@@ -28,16 +45,31 @@ class MigrateAccountConversations < ActiveRecord::Migration[5.2]
 
   private
 
+  def estimate_rows(query)
+    result = exec_query("EXPLAIN #{query.to_sql}").first
+    result['QUERY PLAN'].scan(/ rows=([\d]+)/).first&.first&.to_i || 0
+  end
+
+  def say_progress(migrated, total, started_time)
+    status = "Migrated #{migrated} rows"
+
+    percentage = 100.0 * migrated / total
+    status += " (~#{sprintf('%.2f', percentage)}%, "
+
+    remaining_time = (100.0 - percentage) * (Time.zone.now - started_time) / percentage
+
+    status += "#{(remaining_time / 60).to_i}:"
+    status += sprintf('%02d', remaining_time.to_i % 60)
+    status += ' remaining)'
+
+    say status, true
+  end
+
   def local_direct_statuses
-    Status.unscoped
-          .local
-          .where(visibility: :direct)
-          .includes(:account, mentions: :account)
+    Status.unscoped.local.where(visibility: :direct)
   end
 
   def notifications_about_direct_statuses
-    Notification.joins(mention: :status)
-                .where(activity_type: 'Mention', statuses: { visibility: :direct })
-                .includes(:account, mention: { status: [:account, mentions: :account] })
+    Notification.joins(mention: :status).where(activity_type: 'Mention', statuses: { visibility: :direct })
   end
 end
diff --git a/lib/cli.rb b/lib/cli.rb
index bff6d5809..a810c632a 100644
--- a/lib/cli.rb
+++ b/lib/cli.rb
@@ -6,6 +6,7 @@ require_relative 'mastodon/emoji_cli'
 require_relative 'mastodon/accounts_cli'
 require_relative 'mastodon/feeds_cli'
 require_relative 'mastodon/settings_cli'
+require_relative 'mastodon/domains_cli'
 
 module Mastodon
   class CLI < Thor
@@ -27,5 +28,8 @@ module Mastodon
 
     desc 'settings SUBCOMMAND ...ARGS', 'Manage dynamic settings'
     subcommand 'settings', Mastodon::SettingsCLI
+
+    desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
+    subcommand 'domains', Mastodon::DomainsCLI
   end
 end
diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb
index 055333080..142436c19 100644
--- a/lib/mastodon/accounts_cli.rb
+++ b/lib/mastodon/accounts_cli.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-require 'rubygems/package'
+require 'set'
 require_relative '../../config/boot'
 require_relative '../../config/environment'
 require_relative 'cli_helper'
@@ -10,6 +10,7 @@ module Mastodon
     def self.exit_on_failure?
       true
     end
+
     option :all, type: :boolean
     desc 'rotate [USERNAME]', 'Generate and broadcast new keys'
     long_desc <<-LONG_DESC
@@ -210,33 +211,25 @@ module Mastodon
       Accounts that have had confirmed activity within the last week
       are excluded from the checks.
 
-      If 10 or more accounts from the same domain cannot be queried
-      due to a connection error (such as missing DNS records) then
-      the domain is considered dead, and all other accounts from it
-      are deleted without further querying.
+      Domains that are unreachable are not checked.
 
       With the --dry-run option, no deletes will actually be carried
       out.
     LONG_DESC
     def cull
-      domain_thresholds = Hash.new { |hash, key| hash[key] = 0 }
-      skip_threshold    = 7.days.ago
-      culled            = 0
-      dead_servers      = []
-      dry_run           = options[:dry_run] ? ' (DRY RUN)' : ''
+      skip_threshold = 7.days.ago
+      culled         = 0
+      skip_domains   = Set.new
+      dry_run        = options[:dry_run] ? ' (DRY RUN)' : ''
 
       Account.remote.where(protocol: :activitypub).partitioned.find_each do |account|
         next if account.updated_at >= skip_threshold || (account.last_webfingered_at.present? && account.last_webfingered_at >= skip_threshold)
 
-        unless dead_servers.include?(account.domain)
+        unless skip_domains.include?(account.domain)
           begin
             code = Request.new(:head, account.uri).perform(&:code)
           rescue HTTP::ConnectionError
-            domain_thresholds[account.domain] += 1
-
-            if domain_thresholds[account.domain] >= 10
-              dead_servers << account.domain
-            end
+            skip_domains << account.domain
           rescue StandardError
             next
           end
@@ -255,24 +248,12 @@ module Mastodon
         end
       end
 
-      # Remove dead servers
-      unless dead_servers.empty? || options[:dry_run]
-        dead_servers.each do |domain|
-          Account.where(domain: domain).find_each do |account|
-            SuspendAccountService.new.call(account)
-            account.destroy
-            culled += 1
-            say('.', :green, false)
-          end
-        end
-      end
-
       say
-      say("Removed #{culled} accounts (#{dead_servers.size} dead servers)#{dry_run}", :green)
+      say("Removed #{culled} accounts. #{skip_domains.size} servers skipped#{dry_run}", skip_domains.empty? ? :green : :yellow)
 
-      unless dead_servers.empty?
-        say('R.I.P.:', :yellow)
-        dead_servers.each { |domain| say('    ' + domain) }
+      unless skip_domains.empty?
+        say('The following servers were not available during the check:', :yellow)
+        skip_domains.each { |domain| say('    ' + domain) }
       end
     end
 
diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
new file mode 100644
index 000000000..a7a5caa11
--- /dev/null
+++ b/lib/mastodon/domains_cli.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require_relative '../../config/boot'
+require_relative '../../config/environment'
+require_relative 'cli_helper'
+
+module Mastodon
+  class DomainsCLI < Thor
+    def self.exit_on_failure?
+      true
+    end
+
+    option :dry_run, type: :boolean
+    desc 'purge DOMAIN', 'Remove accounts from a DOMAIN without a trace'
+    long_desc <<-LONG_DESC
+      Remove all accounts from a given DOMAIN without leaving behind any
+      records. Unlike a suspension, if the DOMAIN still exists in the wild,
+      it means the accounts could return if they are resolved again.
+    LONG_DESC
+    def purge(domain)
+      removed = 0
+      dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
+
+      Account.where(domain: domain).find_each do |account|
+        unless options[:dry_run]
+          SuspendAccountService.new.call(account)
+          account.destroy
+        end
+
+        removed += 1
+        say('.', :green, false)
+      end
+
+      DomainBlock.where(domain: domain).destroy_all
+
+      say
+      say("Removed #{removed} accounts#{dry_run}", :green)
+    end
+  end
+end
diff --git a/lib/mastodon/emoji_cli.rb b/lib/mastodon/emoji_cli.rb
index 1987c6394..2262040d4 100644
--- a/lib/mastodon/emoji_cli.rb
+++ b/lib/mastodon/emoji_cli.rb
@@ -10,6 +10,7 @@ module Mastodon
     def self.exit_on_failure?
       true
     end
+
     option :prefix
     option :suffix
     option :overwrite, type: :boolean
diff --git a/lib/mastodon/feeds_cli.rb b/lib/mastodon/feeds_cli.rb
index 817ed4e79..fe11c3df4 100644
--- a/lib/mastodon/feeds_cli.rb
+++ b/lib/mastodon/feeds_cli.rb
@@ -9,6 +9,7 @@ module Mastodon
     def self.exit_on_failure?
       true
     end
+
     option :all, type: :boolean, default: false
     option :background, type: :boolean, default: false
     option :dry_run, type: :boolean, default: false
@@ -58,7 +59,7 @@ module Mastodon
         account = Account.find_local(username)
 
         if account.nil?
-          say("Account #{username} is not found", :red)
+          say('No such account', :red)
           exit(1)
         end
 
diff --git a/lib/mastodon/media_cli.rb b/lib/mastodon/media_cli.rb
index 95d2a8d64..179d1b6b5 100644
--- a/lib/mastodon/media_cli.rb
+++ b/lib/mastodon/media_cli.rb
@@ -9,6 +9,7 @@ module Mastodon
     def self.exit_on_failure?
       true
     end
+
     option :days, type: :numeric, default: 7
     option :background, type: :boolean, default: false
     option :verbose, type: :boolean, default: false
diff --git a/lib/mastodon/settings_cli.rb b/lib/mastodon/settings_cli.rb
index 69485600a..c81cfbe52 100644
--- a/lib/mastodon/settings_cli.rb
+++ b/lib/mastodon/settings_cli.rb
@@ -9,6 +9,7 @@ module Mastodon
     def self.exit_on_failure?
       true
     end
+
     desc 'open', 'Open registrations'
     def open
       Setting.open_registrations = true