diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index ff43ab4e55..c1d580e515 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -318,7 +318,6 @@ RSpec/LetSetup:
     - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
     - 'spec/controllers/api/v1/filters_controller_spec.rb'
     - 'spec/controllers/api/v1/followed_tags_controller_spec.rb'
-    - 'spec/controllers/api/v1/tags_controller_spec.rb'
     - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
     - 'spec/controllers/api/v2/filters/keywords_controller_spec.rb'
     - 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
@@ -440,45 +439,6 @@ RSpec/SubjectStub:
     - 'spec/services/unallow_domain_service_spec.rb'
     - 'spec/validators/blacklisted_email_validator_spec.rb'
 
-# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
-RSpec/VerifiedDoubles:
-  Exclude:
-    - 'spec/controllers/admin/change_emails_controller_spec.rb'
-    - 'spec/controllers/admin/confirmations_controller_spec.rb'
-    - 'spec/controllers/admin/disputes/appeals_controller_spec.rb'
-    - 'spec/controllers/admin/domain_allows_controller_spec.rb'
-    - 'spec/controllers/admin/domain_blocks_controller_spec.rb'
-    - 'spec/controllers/api/v1/reports_controller_spec.rb'
-    - 'spec/controllers/api/web/embeds_controller_spec.rb'
-    - 'spec/controllers/auth/sessions_controller_spec.rb'
-    - 'spec/controllers/disputes/appeals_controller_spec.rb'
-    - 'spec/helpers/statuses_helper_spec.rb'
-    - 'spec/lib/suspicious_sign_in_detector_spec.rb'
-    - 'spec/models/account/field_spec.rb'
-    - 'spec/models/session_activation_spec.rb'
-    - 'spec/models/setting_spec.rb'
-    - 'spec/services/account_search_service_spec.rb'
-    - 'spec/services/post_status_service_spec.rb'
-    - 'spec/services/search_service_spec.rb'
-    - 'spec/validators/blacklisted_email_validator_spec.rb'
-    - 'spec/validators/disallowed_hashtags_validator_spec.rb'
-    - 'spec/validators/email_mx_validator_spec.rb'
-    - 'spec/validators/follow_limit_validator_spec.rb'
-    - 'spec/validators/note_length_validator_spec.rb'
-    - 'spec/validators/poll_validator_spec.rb'
-    - 'spec/validators/status_length_validator_spec.rb'
-    - 'spec/validators/status_pin_validator_spec.rb'
-    - 'spec/validators/unique_username_validator_spec.rb'
-    - 'spec/validators/unreserved_username_validator_spec.rb'
-    - 'spec/validators/url_validator_spec.rb'
-    - 'spec/views/statuses/show.html.haml_spec.rb'
-    - 'spec/workers/activitypub/processing_worker_spec.rb'
-    - 'spec/workers/admin/domain_purge_worker_spec.rb'
-    - 'spec/workers/domain_block_worker_spec.rb'
-    - 'spec/workers/domain_clear_media_worker_spec.rb'
-    - 'spec/workers/feed_insert_worker_spec.rb'
-    - 'spec/workers/regeneration_worker_spec.rb'
-
 # This cop supports unsafe autocorrection (--autocorrect-all).
 Rails/ApplicationController:
   Exclude:
@@ -759,7 +719,6 @@ Rails/WhereExists:
     - 'app/workers/move_worker.rb'
     - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb'
     - 'lib/tasks/tests.rake'
-    - 'spec/controllers/api/v1/tags_controller_spec.rb'
     - 'spec/models/account_spec.rb'
     - 'spec/services/activitypub/process_collection_service_spec.rb'
     - 'spec/services/purge_domain_service_spec.rb'
diff --git a/Gemfile.lock b/Gemfile.lock
index bdbeb79221..5f3678fe58 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -106,7 +106,7 @@ GEM
     aws-sdk-kms (1.67.0)
       aws-sdk-core (~> 3, >= 3.174.0)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.125.0)
+    aws-sdk-s3 (1.126.0)
       aws-sdk-core (~> 3, >= 3.174.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.4)
diff --git a/app/controllers/admin/webhooks_controller.rb b/app/controllers/admin/webhooks_controller.rb
index 01d9ba8ce2..f1aad7c4b5 100644
--- a/app/controllers/admin/webhooks_controller.rb
+++ b/app/controllers/admin/webhooks_controller.rb
@@ -28,6 +28,7 @@ module Admin
       authorize :webhook, :create?
 
       @webhook = Webhook.new(resource_params)
+      @webhook.current_account = current_account
 
       if @webhook.save
         redirect_to admin_webhook_path(@webhook)
@@ -39,10 +40,12 @@ module Admin
     def update
       authorize @webhook, :update?
 
+      @webhook.current_account = current_account
+
       if @webhook.update(resource_params)
         redirect_to admin_webhook_path(@webhook)
       else
-        render :show
+        render :edit
       end
     end
 
diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb
index 63644f85e2..b3ca2f7903 100644
--- a/app/controllers/api/v1/conversations_controller.rb
+++ b/app/controllers/api/v1/conversations_controller.rb
@@ -19,6 +19,11 @@ class Api::V1::ConversationsController < Api::BaseController
     render json: @conversation, serializer: REST::ConversationSerializer
   end
 
+  def unread
+    @conversation.update!(unread: true)
+    render json: @conversation, serializer: REST::ConversationSerializer
+  end
+
   def destroy
     @conversation.destroy!
     render_empty
diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb
index dff2425d06..2913472b04 100644
--- a/app/controllers/api/v1/statuses/histories_controller.rb
+++ b/app/controllers/api/v1/statuses/histories_controller.rb
@@ -8,11 +8,15 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController
 
   def show
     cache_if_unauthenticated!
-    render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer
+    render json: status_edits, each_serializer: REST::StatusEditSerializer
   end
 
   private
 
+  def status_edits
+    @status.edits.includes(:account, status: [:account]).to_a.presence || [@status.build_snapshot(at_time: @status.edited_at || @status.created_at)]
+  end
+
   def set_status
     @status = Status.find(params[:status_id])
     authorize @status, :show?
diff --git a/app/controllers/api/v2/admin/accounts_controller.rb b/app/controllers/api/v2/admin/accounts_controller.rb
index 0c451f778c..65cf0c4db4 100644
--- a/app/controllers/api/v2/admin/accounts_controller.rb
+++ b/app/controllers/api/v2/admin/accounts_controller.rb
@@ -18,6 +18,14 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
 
   private
 
+  def next_path
+    api_v2_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue?
+  end
+
+  def prev_path
+    api_v2_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty?
+  end
+
   def filtered_accounts
     AccountFilter.new(translated_filter_params).results
   end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index ae89cec780..889ca7f402 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -24,13 +24,4 @@ module SettingsHelper
       safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
     end
   end
-
-  def picture_hint(hint, picture)
-    if picture.original_filename.nil?
-      hint
-    else
-      link = link_to t('generic.delete'), settings_profile_picture_path(picture.name.to_s), data: { method: :delete }
-      safe_join([hint, link], '<br/>'.html_safe)
-    end
-  end
 end
diff --git a/app/javascript/images/friends-cropped.png b/app/javascript/images/friends-cropped.png
new file mode 100755
index 0000000000..b13e16a580
Binary files /dev/null and b/app/javascript/images/friends-cropped.png differ
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx
index 0f3b85388c..dd5aff1d8e 100644
--- a/app/javascript/mastodon/components/account.jsx
+++ b/app/javascript/mastodon/components/account.jsx
@@ -1,6 +1,6 @@
 import PropTypes from 'prop-types';
 
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 import classNames from 'classnames';
 import { Link } from 'react-router-dom';
@@ -49,6 +49,7 @@ class Account extends ImmutablePureComponent {
     actionTitle: PropTypes.string,
     defaultAction: PropTypes.string,
     onActionClick: PropTypes.func,
+    withBio: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -80,7 +81,7 @@ class Account extends ImmutablePureComponent {
   };
 
   render () {
-    const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
+    const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
 
     if (!account) {
       return <EmptyAccount size={size} minimal={minimal} />;
@@ -171,6 +172,15 @@ class Account extends ImmutablePureComponent {
             </div>
           )}
         </div>
+
+        {withBio && (account.get('note').length > 0 ? (
+          <div
+            className='account__note translate'
+            dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
+          />
+        ) : (
+          <div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
+        ))}
       </div>
     );
   }
diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.jsx b/app/javascript/mastodon/components/autosuggest_hashtag.jsx
deleted file mode 100644
index b509f48df0..0000000000
--- a/app/javascript/mastodon/components/autosuggest_hashtag.jsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { FormattedMessage } from 'react-intl';
-
-import ShortNumber from 'mastodon/components/short_number';
-
-export default class AutosuggestHashtag extends PureComponent {
-
-  static propTypes = {
-    tag: PropTypes.shape({
-      name: PropTypes.string.isRequired,
-      url: PropTypes.string,
-      history: PropTypes.array,
-    }).isRequired,
-  };
-
-  render() {
-    const { tag } = this.props;
-    const weeklyUses = tag.history && (
-      <ShortNumber
-        value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
-      />
-    );
-
-    return (
-      <div className='autosuggest-hashtag'>
-        <div className='autosuggest-hashtag__name'>
-          #<strong>{tag.name}</strong>
-        </div>
-        {tag.history !== undefined && (
-          <div className='autosuggest-hashtag__uses'>
-            <FormattedMessage
-              id='autosuggest_hashtag.per_week'
-              defaultMessage='{count} per week'
-              values={{ count: weeklyUses }}
-            />
-          </div>
-        )}
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.tsx b/app/javascript/mastodon/components/autosuggest_hashtag.tsx
new file mode 100644
index 0000000000..c6798054db
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_hashtag.tsx
@@ -0,0 +1,42 @@
+import { FormattedMessage } from 'react-intl';
+
+import ShortNumber from 'mastodon/components/short_number';
+
+interface Props {
+  tag: {
+    name: string;
+    url?: string;
+    history?: Array<{
+      uses: number;
+      accounts: string;
+      day: string;
+    }>;
+    following?: boolean;
+    type: 'hashtag';
+  };
+}
+
+export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
+  const weeklyUses = tag.history && (
+    <ShortNumber
+      value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
+    />
+  );
+
+  return (
+    <div className='autosuggest-hashtag'>
+      <div className='autosuggest-hashtag__name'>
+        #<strong>{tag.name}</strong>
+      </div>
+      {tag.history !== undefined && (
+        <div className='autosuggest-hashtag__uses'>
+          <FormattedMessage
+            id='autosuggest_hashtag.per_week'
+            defaultMessage='{count} per week'
+            values={{ count: weeklyUses }}
+          />
+        </div>
+      )}
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx
index 890f94928b..06cbb5d75b 100644
--- a/app/javascript/mastodon/components/autosuggest_input.jsx
+++ b/app/javascript/mastodon/components/autosuggest_input.jsx
@@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 
 import AutosuggestEmoji from './autosuggest_emoji';
-import AutosuggestHashtag from './autosuggest_hashtag';
+import { AutosuggestHashtag } from './autosuggest_hashtag';
 
 const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
   let word;
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx
index 463d2e94c1..230e4f6572 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.jsx
+++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx
@@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 
 import AutosuggestEmoji from './autosuggest_emoji';
-import AutosuggestHashtag from './autosuggest_hashtag';
+import { AutosuggestHashtag } from './autosuggest_hashtag';
 
 const textAtCursorMatchesToken = (str, caretPosition) => {
   let word;
diff --git a/app/javascript/mastodon/components/verified_badge.tsx b/app/javascript/mastodon/components/verified_badge.tsx
index 6b421ba42c..9a6adcfa86 100644
--- a/app/javascript/mastodon/components/verified_badge.tsx
+++ b/app/javascript/mastodon/components/verified_badge.tsx
@@ -1,11 +1,27 @@
 import { Icon } from './icon';
 
+const domParser = new DOMParser();
+
+const stripRelMe = (html: string) => {
+  const document = domParser.parseFromString(html, 'text/html').documentElement;
+
+  document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
+    link.rel = link.rel
+      .split(' ')
+      .filter((x: string) => x !== 'me')
+      .join(' ');
+  });
+
+  const body = document.querySelector('body');
+  return body ? { __html: body.innerHTML } : undefined;
+};
+
 interface Props {
   link: string;
 }
 export const VerifiedBadge: React.FC<Props> = ({ link }) => (
   <span className='verified-badge'>
     <Icon id='check' className='verified-badge__mark' />
-    <span dangerouslySetInnerHTML={{ __html: link }} />
+    <span dangerouslySetInnerHTML={stripRelMe(link)} />
   </span>
 );
diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
index 795b859ce4..936dee12e3 100644
--- a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
+++ b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
@@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
 import ColumnHeader from 'mastodon/components/column_header';
 import StatusList from 'mastodon/components/status_list';
 import Column from 'mastodon/features/ui/components/column';
+import { getStatusList } from 'mastodon/selectors';
 
 const messages = defineMessages({
   heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
 });
 
 const mapStateToProps = state => ({
-  statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
+  statusIds: getStatusList(state, 'bookmarks'),
   isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
   hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
 });
diff --git a/app/javascript/mastodon/features/community_timeline/index.jsx b/app/javascript/mastodon/features/community_timeline/index.jsx
index a18da2f642..7e3b9babe9 100644
--- a/app/javascript/mastodon/features/community_timeline/index.jsx
+++ b/app/javascript/mastodon/features/community_timeline/index.jsx
@@ -140,11 +140,8 @@ class CommunityTimeline extends PureComponent {
           <ColumnSettingsContainer columnId={columnId} />
         </ColumnHeader>
 
-        <DismissableBanner id='community_timeline'>
-          <FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} />
-        </DismissableBanner>
-
         <StatusListContainer
+          prepend={<DismissableBanner id='community_timeline'><FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} /></DismissableBanner>}
           trackScroll={!pinned}
           scrollKey={`community_timeline-${columnId}`}
           timelineId={`community${onlyMedia ? ':media' : ''}`}
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
index 79551b512f..494b8d8624 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
@@ -389,7 +389,7 @@ class EmojiPickerDropdown extends PureComponent {
           {button || <img
             className={classNames('emojione', { 'pulse-loading': active && loading })}
             alt='🙂'
-            src={`${assetHost}/emoji/1f602.svg`}
+            src={`${assetHost}/emoji/1f642.svg`}
           />}
         </div>
 
diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx
index df91337fdd..49c667f027 100644
--- a/app/javascript/mastodon/features/explore/links.jsx
+++ b/app/javascript/mastodon/features/explore/links.jsx
@@ -35,7 +35,7 @@ class Links extends PureComponent {
 
     const banner = (
       <DismissableBanner id='explore/links'>
-        <FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These news stories are being talked about by people on this and other servers of the decentralized network right now.' />
+        <FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.' />
       </DismissableBanner>
     );
 
diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx
index abacf333dd..eb2fe777a6 100644
--- a/app/javascript/mastodon/features/explore/statuses.jsx
+++ b/app/javascript/mastodon/features/explore/statuses.jsx
@@ -11,9 +11,10 @@ import { debounce } from 'lodash';
 import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
 import DismissableBanner from 'mastodon/components/dismissable_banner';
 import StatusList from 'mastodon/components/status_list';
+import { getStatusList } from 'mastodon/selectors';
 
 const mapStateToProps = state => ({
-  statusIds: state.getIn(['status_lists', 'trending', 'items']),
+  statusIds: getStatusList(state, 'trending'),
   isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
   hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
 });
@@ -46,7 +47,7 @@ class Statuses extends PureComponent {
     return (
       <>
         <DismissableBanner id='explore/statuses'>
-          <FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These posts from this and other servers in the decentralized network are gaining traction on this server right now.' />
+          <FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.' />
         </DismissableBanner>
 
         <StatusList
diff --git a/app/javascript/mastodon/features/explore/tags.jsx b/app/javascript/mastodon/features/explore/tags.jsx
index ba6f31cd0a..f558b48a60 100644
--- a/app/javascript/mastodon/features/explore/tags.jsx
+++ b/app/javascript/mastodon/features/explore/tags.jsx
@@ -34,7 +34,7 @@ class Tags extends PureComponent {
 
     const banner = (
       <DismissableBanner id='explore/tags'>
-        <FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These hashtags are gaining traction among people on this and other servers of the decentralized network right now.' />
+        <FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.' />
       </DismissableBanner>
     );
 
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.jsx b/app/javascript/mastodon/features/favourited_statuses/index.jsx
index 4902ddc28b..abce7ac053 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.jsx
+++ b/app/javascript/mastodon/features/favourited_statuses/index.jsx
@@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/acti
 import ColumnHeader from 'mastodon/components/column_header';
 import StatusList from 'mastodon/components/status_list';
 import Column from 'mastodon/features/ui/components/column';
+import { getStatusList } from 'mastodon/selectors';
 
 const messages = defineMessages({
   heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
 });
 
 const mapStateToProps = state => ({
-  statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  statusIds: getStatusList(state, 'favourites'),
   isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
   hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
 });
diff --git a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx
new file mode 100644
index 0000000000..a3780dd7f2
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import background from 'mastodon/../images/friends-cropped.png';
+
+import DismissableBanner from 'mastodon/components/dismissable_banner';
+
+
+export const ExplorePrompt = () => (
+  <DismissableBanner id='home.explore_prompt'>
+    <img src={background} alt='' className='dismissable-banner__background-image' />
+
+    <h1><FormattedMessage id='home.explore_prompt.title' defaultMessage='This is your home base within Mastodon.' /></h1>
+    <p><FormattedMessage id='home.explore_prompt.body' defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:" /></p>
+
+    <div className='dismissable-banner__message__actions'>
+      <Link to='/explore' className='button'><FormattedMessage id='home.actions.go_to_explore' defaultMessage="See what's trending" /></Link>
+      <Link to='/explore/suggestions' className='button button-tertiary'><FormattedMessage id='home.actions.go_to_suggestions' defaultMessage='Find people to follow' /></Link>
+    </div>
+  </DismissableBanner>
+);
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx
index c9fe078755..389efcc875 100644
--- a/app/javascript/mastodon/features/home_timeline/index.jsx
+++ b/app/javascript/mastodon/features/home_timeline/index.jsx
@@ -5,14 +5,16 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 import classNames from 'classnames';
 import { Helmet } from 'react-helmet';
-import { Link } from 'react-router-dom';
 
+import { List as ImmutableList } from 'immutable';
 import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
 
 import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
 import { IconWithBadge } from 'mastodon/components/icon_with_badge';
 import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
 import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
+import { me } from 'mastodon/initial_state';
 
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { expandHomeTimeline } from '../../actions/timelines';
@@ -20,6 +22,7 @@ import Column from '../../components/column';
 import ColumnHeader from '../../components/column_header';
 import StatusListContainer from '../ui/containers/status_list_container';
 
+import { ExplorePrompt } from './components/explore_prompt';
 import ColumnSettingsContainer from './containers/column_settings_container';
 
 const messages = defineMessages({
@@ -28,12 +31,33 @@ const messages = defineMessages({
   hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
 });
 
+const getHomeFeedSpeed = createSelector([
+  state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
+  state => state.get('statuses'),
+], (statusIds, statusMap) => {
+  const statuses = statusIds.map(id => statusMap.get(id)).filter(status => status.get('account') !== me).take(20);
+  const oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0));
+  const newest = new Date(statuses.getIn([0, 'created_at'], 0));
+  const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds
+
+  return {
+    gap: averageGap,
+    newest,
+  };
+});
+
+const homeTooSlow = createSelector(getHomeFeedSpeed, speed =>
+  speed.gap > (30 * 60) // If the average gap between posts is more than 20 minutes
+  || (Date.now() - speed.newest) > (1000 * 3600) // If the most recent post is from over an hour ago
+);
+
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
   isPartial: state.getIn(['timelines', 'home', 'isPartial']),
   hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
   unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
   showAnnouncements: state.getIn(['announcements', 'show']),
+  tooSlow: homeTooSlow(state),
 });
 
 class HomeTimeline extends PureComponent {
@@ -52,6 +76,7 @@ class HomeTimeline extends PureComponent {
     hasAnnouncements: PropTypes.bool,
     unreadAnnouncements: PropTypes.number,
     showAnnouncements: PropTypes.bool,
+    tooSlow: PropTypes.bool,
   };
 
   handlePin = () => {
@@ -121,11 +146,11 @@ class HomeTimeline extends PureComponent {
   };
 
   render () {
-    const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
+    const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
     const pinned = !!columnId;
     const { signedIn } = this.context.identity;
 
-    let announcementsButton = null;
+    let announcementsButton, banner;
 
     if (hasAnnouncements) {
       announcementsButton = (
@@ -141,6 +166,10 @@ class HomeTimeline extends PureComponent {
       );
     }
 
+    if (tooSlow) {
+      banner = <ExplorePrompt />;
+    }
+
     return (
       <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
         <ColumnHeader
@@ -160,11 +189,13 @@ class HomeTimeline extends PureComponent {
 
         {signedIn ? (
           <StatusListContainer
+            prepend={banner}
+            alwaysPrepend
             trackScroll={!pinned}
             scrollKey={`home_timeline-${columnId}`}
             onLoadMore={this.handleLoadMore}
             timelineId='home'
-            emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
+            emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up.' />}
             bindToDocument={!multiColumn}
           />
         ) : <NotSignedInIndicator />}
diff --git a/app/javascript/mastodon/features/onboarding/components/step.jsx b/app/javascript/mastodon/features/onboarding/components/step.jsx
index 0f478f26a3..379f433040 100644
--- a/app/javascript/mastodon/features/onboarding/components/step.jsx
+++ b/app/javascript/mastodon/features/onboarding/components/step.jsx
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
 import { Check } from 'mastodon/components/check';
 import { Icon }  from 'mastodon/components/icon';
 
+import ArrowSmallRight from './arrow_small_right';
+
 const Step = ({ label, description, icon, completed, onClick, href }) => {
   const content = (
     <>
@@ -15,11 +17,9 @@ const Step = ({ label, description, icon, completed, onClick, href }) => {
         <p>{description}</p>
       </div>
 
-      {completed && (
-        <div className='onboarding__steps__item__progress'>
-          <Check />
-        </div>
-      )}
+      <div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
+        {completed ? <Check /> : <ArrowSmallRight />}
+      </div>
     </>
   );
 
diff --git a/app/javascript/mastodon/features/onboarding/follows.jsx b/app/javascript/mastodon/features/onboarding/follows.jsx
index 8b4ad0b087..472a87f5ec 100644
--- a/app/javascript/mastodon/features/onboarding/follows.jsx
+++ b/app/javascript/mastodon/features/onboarding/follows.jsx
@@ -12,20 +12,11 @@ import Column from 'mastodon/components/column';
 import ColumnBackButton from 'mastodon/components/column_back_button';
 import { EmptyAccount } from 'mastodon/components/empty_account';
 import Account from 'mastodon/containers/account_container';
-import { me } from 'mastodon/initial_state';
-import { makeGetAccount } from 'mastodon/selectors';
 
-import ProgressIndicator from './components/progress_indicator';
-
-const mapStateToProps = () => {
-  const getAccount = makeGetAccount();
-
-  return state => ({
-    account: getAccount(state, me),
-    suggestions: state.getIn(['suggestions', 'items']),
-    isLoading: state.getIn(['suggestions', 'isLoading']),
-  });
-};
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['suggestions', 'items']),
+  isLoading: state.getIn(['suggestions', 'isLoading']),
+});
 
 class Follows extends PureComponent {
 
@@ -33,7 +24,6 @@ class Follows extends PureComponent {
     onBack: PropTypes.func,
     dispatch: PropTypes.func.isRequired,
     suggestions: ImmutablePropTypes.list,
-    account: ImmutablePropTypes.map,
     isLoading: PropTypes.bool,
     multiColumn: PropTypes.bool,
   };
@@ -49,7 +39,7 @@ class Follows extends PureComponent {
   }
 
   render () {
-    const { onBack, isLoading, suggestions, account, multiColumn } = this.props;
+    const { onBack, isLoading, suggestions, multiColumn } = this.props;
 
     let loadedContent;
 
@@ -58,7 +48,7 @@ class Follows extends PureComponent {
     } else if (suggestions.isEmpty()) {
       loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
     } else {
-      loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} />);
+      loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />);
     }
 
     return (
@@ -71,8 +61,6 @@ class Follows extends PureComponent {
             <p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
           </div>
 
-          <ProgressIndicator steps={7} completed={account.get('following_count') * 1} />
-
           <div className='follow-recommendations'>
             {loadedContent}
           </div>
diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx
index f1447b771e..3627f76cd0 100644
--- a/app/javascript/mastodon/features/onboarding/index.jsx
+++ b/app/javascript/mastodon/features/onboarding/index.jsx
@@ -19,6 +19,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding';
 import Column from 'mastodon/features/ui/components/column';
 import { me } from 'mastodon/initial_state';
 import { makeGetAccount } from 'mastodon/selectors';
+import { assetHost } from 'mastodon/utils/config';
 
 import ArrowSmallRight from './components/arrow_small_right';
 import Step from './components/step';
@@ -122,21 +123,22 @@ class Onboarding extends ImmutablePureComponent {
           <div className='onboarding__steps'>
             <Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
             <Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
-            <Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} />
+            <Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
             <Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
           </div>
 
-          <p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage='Want to skip right ahead?' /></p>
+          <p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
 
           <div className='onboarding__links'>
             <Link to='/explore' className='onboarding__link'>
+              <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
               <ArrowSmallRight />
-              <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
             </Link>
-          </div>
 
-          <div className='onboarding__footer'>
-            <button className='link-button' onClick={this.handleClose}><FormattedMessage id='onboarding.actions.close' defaultMessage="Don't show this screen again" /></button>
+            <Link to='/home' className='onboarding__link'>
+              <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
+              <ArrowSmallRight />
+            </Link>
           </div>
         </div>
 
diff --git a/app/javascript/mastodon/features/onboarding/share.jsx b/app/javascript/mastodon/features/onboarding/share.jsx
index 6871793026..c5b185a244 100644
--- a/app/javascript/mastodon/features/onboarding/share.jsx
+++ b/app/javascript/mastodon/features/onboarding/share.jsx
@@ -177,13 +177,13 @@ class Share extends PureComponent {
 
           <div className='onboarding__links'>
             <Link to='/home' className='onboarding__link'>
+              <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
               <ArrowSmallRight />
-              <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Go to your home feed' />
             </Link>
 
             <Link to='/explore' className='onboarding__link'>
+              <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
               <ArrowSmallRight />
-              <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
             </Link>
           </div>
 
diff --git a/app/javascript/mastodon/features/pinned_statuses/index.jsx b/app/javascript/mastodon/features/pinned_statuses/index.jsx
index a93e82cfae..f09d5471e3 100644
--- a/app/javascript/mastodon/features/pinned_statuses/index.jsx
+++ b/app/javascript/mastodon/features/pinned_statuses/index.jsx
@@ -8,6 +8,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { connect } from 'react-redux';
 
+import { getStatusList } from 'mastodon/selectors';
+
 import { fetchPinnedStatuses } from '../../actions/pin_statuses';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
 import StatusList from '../../components/status_list';
@@ -18,7 +20,7 @@ const messages = defineMessages({
 });
 
 const mapStateToProps = state => ({
-  statusIds: state.getIn(['status_lists', 'pins', 'items']),
+  statusIds: getStatusList(state, 'pins'),
   hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
 });
 
diff --git a/app/javascript/mastodon/features/public_timeline/index.jsx b/app/javascript/mastodon/features/public_timeline/index.jsx
index 01b02d4024..d77b76a63e 100644
--- a/app/javascript/mastodon/features/public_timeline/index.jsx
+++ b/app/javascript/mastodon/features/public_timeline/index.jsx
@@ -142,11 +142,8 @@ class PublicTimeline extends PureComponent {
           <ColumnSettingsContainer columnId={columnId} />
         </ColumnHeader>
 
-        <DismissableBanner id='public_timeline'>
-          <FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' />
-        </DismissableBanner>
-
         <StatusListContainer
+          prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' /></DismissableBanner>}
           timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
           onLoadMore={this.handleLoadMore}
           trackScroll={!pinned}
diff --git a/app/javascript/mastodon/features/ui/components/header.jsx b/app/javascript/mastodon/features/ui/components/header.jsx
index bb6747c00c..05abc1ca63 100644
--- a/app/javascript/mastodon/features/ui/components/header.jsx
+++ b/app/javascript/mastodon/features/ui/components/header.jsx
@@ -8,6 +8,7 @@ import { Link, withRouter } from 'react-router-dom';
 import { connect } from 'react-redux';
 
 import { openModal } from 'mastodon/actions/modal';
+import { fetchServer } from 'mastodon/actions/server';
 import { Avatar } from 'mastodon/components/avatar';
 import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
 import { registrationsOpen, me } from 'mastodon/initial_state';
@@ -28,6 +29,9 @@ const mapDispatchToProps = (dispatch) => ({
   openClosedRegistrationsModal() {
     dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
   },
+  dispatchServer() {
+    dispatch(fetchServer());
+  }
 });
 
 class Header extends PureComponent {
@@ -40,8 +44,14 @@ class Header extends PureComponent {
     openClosedRegistrationsModal: PropTypes.func,
     location: PropTypes.object,
     signupUrl: PropTypes.string.isRequired,
+    dispatchServer: PropTypes.func
   };
 
+  componentDidMount () {
+    const { dispatchServer } = this.props;
+    dispatchServer();
+  }
+
   render () {
     const { signedIn } = this.context.identity;
     const { location, openClosedRegistrationsModal, signupUrl } = this.props;
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 9f7ffad66c..63ab26bc56 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -52,6 +52,7 @@
   "account.mute_notifications_short": "Mute notifications",
   "account.mute_short": "Mute",
   "account.muted": "Muted",
+  "account.no_bio": "No description provided.",
   "account.open_original_page": "Open original page",
   "account.posts": "Posts",
   "account.posts_with_replies": "Posts and replies",
@@ -197,9 +198,9 @@
   "disabled_account_banner.text": "Your account {disabledAccount} is currently disabled.",
   "dismissable_banner.community_timeline": "These are the most recent public posts from people whose accounts are hosted by {domain}.",
   "dismissable_banner.dismiss": "Dismiss",
-  "dismissable_banner.explore_links": "These news stories are being talked about by people on this and other servers of the decentralized network right now.",
-  "dismissable_banner.explore_statuses": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",
-  "dismissable_banner.explore_tags": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.",
+  "dismissable_banner.explore_links": "These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.",
+  "dismissable_banner.explore_statuses": "These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.",
+  "dismissable_banner.explore_tags": "These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.",
   "dismissable_banner.public_timeline": "These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.",
   "embed.instructions": "Embed this post on your website by copying the code below.",
   "embed.preview": "Here is what it will look like:",
@@ -232,8 +233,7 @@
   "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
   "empty_column.followed_tags": "You have not followed any hashtags yet. When you do, they will show up here.",
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
-  "empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
-  "empty_column.home.suggestions": "See some suggestions",
+  "empty_column.home": "Your home timeline is empty! Follow more people to fill it up.",
   "empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.",
   "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
   "empty_column.mutes": "You haven't muted any users yet.",
@@ -292,9 +292,13 @@
   "hashtag.column_settings.tag_toggle": "Include additional tags for this column",
   "hashtag.follow": "Follow hashtag",
   "hashtag.unfollow": "Unfollow hashtag",
+  "home.actions.go_to_explore": "See what's trending",
+  "home.actions.go_to_suggestions": "Find people to follow",
   "home.column_settings.basic": "Basic",
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
+  "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:",
+  "home.explore_prompt.title": "This is your home base within Mastodon.",
   "home.hide_announcements": "Hide announcements",
   "home.show_announcements": "Show announcements",
   "interaction_modal.description.favourite": "With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.",
@@ -449,28 +453,27 @@
   "notifications_permission_banner.title": "Never miss a thing",
   "onboarding.action.back": "Take me back",
   "onboarding.actions.back": "Take me back",
-  "onboarding.actions.close": "Don't show this screen again",
-  "onboarding.actions.go_to_explore": "See what's trending",
-  "onboarding.actions.go_to_home": "Go to your home feed",
+  "onboarding.actions.go_to_explore": "Take me to trending",
+  "onboarding.actions.go_to_home": "Take me to my home feed",
   "onboarding.compose.template": "Hello #Mastodon!",
   "onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
-  "onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
-  "onboarding.follows.title": "Popular on Mastodon",
+  "onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:",
+  "onboarding.follows.title": "Personalize your home feed",
   "onboarding.share.lead": "Let people know how they can find you on Mastodon!",
   "onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
   "onboarding.share.next_steps": "Possible next steps:",
   "onboarding.share.title": "Share your profile",
-  "onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
-  "onboarding.start.skip": "Want to skip right ahead?",
+  "onboarding.start.lead": "You're now part of Mastodon, a unique, decentralized social media platform where you—not an algorithm—curate your own experience. Let's get you started on this new social frontier:",
+  "onboarding.start.skip": "Don't need help getting started?",
   "onboarding.start.title": "You've made it!",
-  "onboarding.steps.follow_people.body": "You curate your own home feed. Let's fill it with interesting people.",
-  "onboarding.steps.follow_people.title": "Find at least {count, plural, one {one person} other {# people}} to follow",
-  "onboarding.steps.publish_status.body": "Say hello to the world.",
+  "onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.",
+  "onboarding.steps.follow_people.title": "Personalize your home feed",
+  "onboarding.steps.publish_status.body": "Say hello to the world with text, photos, videos, or polls {emoji}",
   "onboarding.steps.publish_status.title": "Make your first post",
-  "onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",
-  "onboarding.steps.setup_profile.title": "Customize your profile",
-  "onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!",
-  "onboarding.steps.share_profile.title": "Share your profile",
+  "onboarding.steps.setup_profile.body": "Boost your interactions by having a comprehensive profile.",
+  "onboarding.steps.setup_profile.title": "Personalize your profile",
+  "onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon",
+  "onboarding.steps.share_profile.title": "Share your Mastodon profile",
   "onboarding.tips.2fa": "<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!",
   "onboarding.tips.accounts_from_other_servers": "<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!",
   "onboarding.tips.migration": "<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!",
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index b67734316b..f92e7fe48d 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -137,3 +137,7 @@ export const getAccountHidden = createSelector([
 ], (hidden, followingOrRequested, isSelf) => {
   return hidden && !(isSelf || followingOrRequested);
 });
+
+export const getStatusList = createSelector([
+  (state, type) => state.getIn(['status_lists', type, 'items']),
+], (items) => items.toList());
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 7498477caa..91828d408a 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -653,11 +653,6 @@ html {
   border: 1px solid lighten($ui-base-color, 8%);
 }
 
-.dismissable-banner {
-  border-left: 1px solid lighten($ui-base-color, 8%);
-  border-right: 1px solid lighten($ui-base-color, 8%);
-}
-
 .status__content,
 .reply-indicator__content {
   a {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index aba5bf6ce0..cc322ca9f1 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1514,12 +1514,37 @@ body > [data-popper-placement] {
   }
 
   &__note {
+    font-size: 14px;
+    font-weight: 400;
     overflow: hidden;
     text-overflow: ellipsis;
     display: -webkit-box;
-    -webkit-line-clamp: 2;
+    -webkit-line-clamp: 1;
     -webkit-box-orient: vertical;
-    color: $ui-secondary-color;
+    margin-top: 10px;
+    color: $darker-text-color;
+
+    &--missing {
+      color: $dark-text-color;
+    }
+
+    p {
+      margin-bottom: 10px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    a {
+      color: inherit;
+
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: none;
+      }
+    }
   }
 }
 
@@ -2617,13 +2642,15 @@ $ui-header-height: 55px;
 .onboarding__link {
   display: flex;
   align-items: center;
+  justify-content: space-between;
   gap: 10px;
   color: $highlight-text-color;
   background: lighten($ui-base-color, 4%);
   border-radius: 8px;
-  padding: 10px;
+  padding: 10px 15px;
   box-sizing: border-box;
-  font-size: 17px;
+  font-size: 14px;
+  font-weight: 500;
   height: 56px;
   text-decoration: none;
 
@@ -2685,6 +2712,7 @@ $ui-header-height: 55px;
     align-items: center;
     gap: 10px;
     padding: 10px;
+    padding-inline-end: 15px;
     margin-bottom: 2px;
     text-decoration: none;
     text-align: start;
@@ -2697,14 +2725,14 @@ $ui-header-height: 55px;
 
     &__icon {
       flex: 0 0 auto;
-      background: $ui-base-color;
       border-radius: 50%;
       display: none;
       align-items: center;
       justify-content: center;
       width: 36px;
       height: 36px;
-      color: $dark-text-color;
+      color: $highlight-text-color;
+      font-size: 1.2rem;
 
       @media screen and (width >= 600px) {
         display: flex;
@@ -2728,16 +2756,33 @@ $ui-header-height: 55px;
       }
     }
 
+    &__go {
+      flex: 0 0 auto;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 21px;
+      height: 21px;
+      color: $highlight-text-color;
+      font-size: 17px;
+
+      svg {
+        height: 1.5em;
+        width: auto;
+      }
+    }
+
     &__description {
       flex: 1 1 auto;
-      line-height: 18px;
+      line-height: 20px;
       white-space: nowrap;
       text-overflow: ellipsis;
       overflow: hidden;
 
       h6 {
-        color: $primary-text-color;
-        font-weight: 700;
+        color: $highlight-text-color;
+        font-weight: 500;
+        font-size: 14px;
         overflow: hidden;
         text-overflow: ellipsis;
       }
@@ -8695,27 +8740,71 @@ noscript {
 }
 
 .dismissable-banner {
-  background: $ui-base-color;
-  border-bottom: 1px solid lighten($ui-base-color, 8%);
-  display: flex;
-  align-items: center;
-  gap: 30px;
+  position: relative;
+  margin: 10px;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  border: 1px solid $highlight-text-color;
+  background: rgba($highlight-text-color, 0.15);
+  padding-inline-end: 45px;
+  overflow: hidden;
+
+  &__background-image {
+    width: 125%;
+    position: absolute;
+    bottom: -25%;
+    inset-inline-end: -25%;
+    z-index: -1;
+    opacity: 0.15;
+    mix-blend-mode: luminosity;
+  }
 
   &__message {
     flex: 1 1 auto;
-    padding: 20px 15px;
-    cursor: default;
-    font-size: 14px;
-    line-height: 18px;
+    padding: 15px;
+    font-size: 15px;
+    line-height: 22px;
+    font-weight: 500;
     color: $primary-text-color;
+
+    p {
+      margin-bottom: 15px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    h1 {
+      color: $highlight-text-color;
+      font-size: 22px;
+      line-height: 33px;
+      font-weight: 700;
+      margin-bottom: 15px;
+    }
+
+    &__actions {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      margin-top: 30px;
+    }
+
+    .button-tertiary {
+      background: rgba($ui-base-color, 0.15);
+      backdrop-filter: blur(8px);
+    }
   }
 
   &__action {
-    padding: 15px;
-    flex: 0 0 auto;
-    display: flex;
-    align-items: center;
-    justify-content: center;
+    position: absolute;
+    inset-inline-end: 0;
+    top: 0;
+    padding: 10px;
+
+    .icon-button {
+      color: $highlight-text-color;
+    }
   }
 }
 
diff --git a/app/lib/request_pool.rb b/app/lib/request_pool.rb
index 6be1722860..86c825498d 100644
--- a/app/lib/request_pool.rb
+++ b/app/lib/request_pool.rb
@@ -28,8 +28,9 @@ class RequestPool
   end
 
   MAX_IDLE_TIME = 30
-  WAIT_TIMEOUT  = 5
   MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i
+  REAPER_FREQUENCY = 30
+  WAIT_TIMEOUT = 5
 
   class Connection
     attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh
@@ -98,7 +99,7 @@ class RequestPool
 
   def initialize
     @pool   = ConnectionPool::SharedConnectionPool.new(size: MAX_POOL_SIZE, timeout: WAIT_TIMEOUT) { |site| Connection.new(site) }
-    @reaper = Reaper.new(self, 30)
+    @reaper = Reaper.new(self, REAPER_FREQUENCY)
     @reaper.run
   end
 
diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb
index 243e892891..0404cbaced 100644
--- a/app/lib/text_formatter.rb
+++ b/app/lib/text_formatter.rb
@@ -79,7 +79,7 @@ class TextFormatter
     cutoff      = url[prefix.length..-1].length > 30
 
     <<~HTML.squish
-      <a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
+      <a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
     HTML
   rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     h(entity[:url])
@@ -122,7 +122,7 @@ class TextFormatter
     display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
 
     <<~HTML.squish
-      <span class="h-card"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span>
+      <span class="h-card" translate="no"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span>
     HTML
   end
 
diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb
index 32fe79ccf7..25a75d8a61 100644
--- a/app/models/account_conversation.rb
+++ b/app/models/account_conversation.rb
@@ -32,14 +32,8 @@ class AccountConversation < ApplicationRecord
   end
 
   def participant_accounts
-    @participant_accounts ||= begin
-      if participant_account_ids.empty?
-        [account]
-      else
-        participants = Account.where(id: participant_account_ids).to_a
-        participants.empty? ? [account] : participants
-      end
-    end
+    @participant_accounts ||= Account.where(id: participant_account_ids).to_a
+    @participant_accounts.presence || [account]
   end
 
   class << self
diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb
index 0be8c5fbce..41b4f57f08 100644
--- a/app/models/user_settings.rb
+++ b/app/models/user_settings.rb
@@ -15,7 +15,7 @@ class UserSettings
   setting :show_application, default: true
   setting :default_language, default: nil
   setting :default_sensitive, default: false
-  setting :default_privacy, default: nil
+  setting :default_privacy, default: nil, in: %w(public unlisted private)
   setting :default_content_type, default: 'text/plain'
   setting :hide_followers_count, default: false
 
@@ -79,7 +79,10 @@ class UserSettings
 
     raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
 
-    typecast_value = self.class.definition_for(key).type_cast(value)
+    setting_definition = self.class.definition_for(key)
+    typecast_value = setting_definition.type_cast(value)
+
+    raise ArgumentError, "Invalid value for setting #{key}: #{typecast_value}" if setting_definition.in.present? && setting_definition.in.exclude?(typecast_value)
 
     if typecast_value.nil?
       @original_hash.delete(key)
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
index c46fce743e..14f33c5fc4 100644
--- a/app/models/webhook.rb
+++ b/app/models/webhook.rb
@@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
     status.updated
   ).freeze
 
+  attr_writer :current_account
+
   scope :enabled, -> { where(enabled: true) }
 
   validates :url, presence: true, url: true
@@ -31,6 +33,7 @@ class Webhook < ApplicationRecord
   validates :events, presence: true
 
   validate :validate_events
+  validate :validate_permissions
   validate :validate_template
 
   before_validation :strip_events
@@ -48,12 +51,31 @@ class Webhook < ApplicationRecord
     update!(enabled: false)
   end
 
+  def required_permissions
+    events.map { |event| Webhook.permission_for_event(event) }
+  end
+
+  def self.permission_for_event(event)
+    case event
+    when 'account.approved', 'account.created', 'account.updated'
+      :manage_users
+    when 'report.created'
+      :manage_reports
+    when 'status.created', 'status.updated'
+      :view_devops
+    end
+  end
+
   private
 
   def validate_events
     errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) }
   end
 
+  def validate_permissions
+    errors.add(:events, :invalid_permissions) if defined?(@current_account) && required_permissions.any? { |permission| !@current_account.user_role.can?(permission) }
+  end
+
   def validate_template
     return if template.blank?
 
diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb
index a2199a333f..577e891b66 100644
--- a/app/policies/webhook_policy.rb
+++ b/app/policies/webhook_policy.rb
@@ -14,7 +14,7 @@ class WebhookPolicy < ApplicationPolicy
   end
 
   def update?
-    role.can?(:manage_webhooks)
+    role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
   end
 
   def enable?
@@ -30,6 +30,6 @@ class WebhookPolicy < ApplicationPolicy
   end
 
   def destroy?
-    role.can?(:manage_webhooks)
+    role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
   end
 end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index e1c1c35fcf..64ace295b9 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -12,6 +12,7 @@ class RemoveStatusService < BaseService
   # @option  [Boolean] :immediate
   # @option  [Boolean] :preserve
   # @option  [Boolean] :original_removed
+  # @option  [Boolean] :skip_streaming
   def call(status, **options)
     @payload  = Oj.dump(event: :delete, payload: status.id.to_s)
     @status   = status
@@ -53,6 +54,9 @@ class RemoveStatusService < BaseService
 
   private
 
+  # The following FeedManager calls all do not result in redis publishes for
+  # streaming, as the `:update` option is false
+
   def remove_from_self
     FeedManager.instance.unpush_from_home(@account, @status)
     FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility?
@@ -77,6 +81,8 @@ class RemoveStatusService < BaseService
     # followers. Here we send a delete to actively mentioned accounts
     # that may not follow the account
 
+    return if skip_streaming?
+
     @status.active_mentions.find_each do |mention|
       redis.publish("timeline:#{mention.account_id}", @payload)
     end
@@ -105,7 +111,7 @@ class RemoveStatusService < BaseService
     # without us being able to do all the fancy stuff
 
     @status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).includes(:account).reorder(nil).find_each do |reblog|
-      RemoveStatusService.new.call(reblog, original_removed: true)
+      RemoveStatusService.new.call(reblog, original_removed: true, skip_streaming: skip_streaming?)
     end
   end
 
@@ -116,6 +122,8 @@ class RemoveStatusService < BaseService
 
     return unless @status.public_visibility?
 
+    return if skip_streaming?
+
     @status.tags.map(&:name).each do |hashtag|
       redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
       redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
@@ -125,6 +133,8 @@ class RemoveStatusService < BaseService
   def remove_from_public
     return unless @status.public_visibility?
 
+    return if skip_streaming?
+
     redis.publish('timeline:public', @payload)
     redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
   end
@@ -132,6 +142,8 @@ class RemoveStatusService < BaseService
   def remove_from_media
     return unless @status.public_visibility?
 
+    return if skip_streaming?
+
     redis.publish('timeline:public:media', @payload)
     redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
   end
@@ -151,4 +163,8 @@ class RemoveStatusService < BaseService
   def permanently?
     @options[:immediate] || !(@options[:preserve] || @status.reported?)
   end
+
+  def skip_streaming?
+    !!@options[:skip_streaming]
+  end
 end
diff --git a/app/views/admin/webhooks/_form.html.haml b/app/views/admin/webhooks/_form.html.haml
index 8d019ff43b..c870e943f4 100644
--- a/app/views/admin/webhooks/_form.html.haml
+++ b/app/views/admin/webhooks/_form.html.haml
@@ -5,7 +5,7 @@
     = f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' }
 
   .fields-group
-    = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+    = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', disabled: Webhook::EVENTS.filter { |event| !current_user.role.can?(Webhook.permission_for_event(event)) }
 
   .fields-group
     = f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' }
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index 45cfbc62e6..4aee7935a2 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -24,7 +24,7 @@ class Scheduler::UserCleanupScheduler
   def clean_discarded_statuses!
     Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
       RemovalWorker.push_bulk(statuses) do |status|
-        [status.id, { 'immediate' => true }]
+        [status.id, { 'immediate' => true, 'skip_streaming' => true }]
       end
     end
   end
diff --git a/config/boot.rb b/config/boot.rb
index 4e379e7db5..3a1d1d6d24 100644
--- a/config/boot.rb
+++ b/config/boot.rb
@@ -6,12 +6,4 @@ end
 ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
 
 require 'bundler/setup' # Set up gems listed in the Gemfile.
-require 'bootsnap' # Speed up boot time by caching expensive operations.
-
-Bootsnap.setup(
-  cache_dir:            File.expand_path('../tmp/cache', __dir__),
-  development_mode:     ENV.fetch('RAILS_ENV', 'development') == 'development',
-  load_path_cache:      true,
-  compile_cache_iseq:   false,
-  compile_cache_yaml:   false
-)
+require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml
index 8aee15659f..a53c7c6e9e 100644
--- a/config/locales/activerecord.en.yml
+++ b/config/locales/activerecord.en.yml
@@ -53,3 +53,7 @@ en:
             position:
               elevated: cannot be higher than your current role
               own_role: cannot be changed with your current role
+        webhook:
+          attributes:
+            events:
+              invalid_permissions: cannot include events you don't have the rights to
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 8cc135c136..040ea71242 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -82,6 +82,7 @@ namespace :api, format: false do
     resources :conversations, only: [:index, :destroy] do
       member do
         post :read
+        post :unread
       end
     end
 
diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb
index f3eb9c0754..762f69cfcb 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -55,6 +55,11 @@ class Sanitize
       end
     end
 
+    TRANSLATE_TRANSFORMER = lambda do |env|
+      node = env[:node]
+      node.remove_attribute('translate') unless node['translate'] == 'no'
+    end
+
     UNSUPPORTED_HREF_TRANSFORMER = lambda do |env|
       return unless env[:node_name] == 'a'
 
@@ -73,9 +78,9 @@ class Sanitize
       elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li),
 
       attributes: {
-        'a' => %w(href rel class title),
+        'a' => %w(href rel class title translate),
         'abbr' => %w(title),
-        'span' => %w(class),
+        'span' => %w(class translate),
         'blockquote' => %w(cite),
         'ol' => %w(start reversed),
         'li' => %w(value),
@@ -96,6 +101,7 @@ class Sanitize
       transformers: [
         CLASS_WHITELIST_TRANSFORMER,
         IMG_TAG_TRANSFORMER,
+        TRANSLATE_TRANSFORMER,
         UNSUPPORTED_HREF_TRANSFORMER,
       ]
     )
@@ -151,7 +157,7 @@ class Sanitize
     MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
       attributes: merge(
         MASTODON_STRICT[:attributes],
-        'a' => %w(href rel class title target)
+        'a' => %w(href rel class title target translate)
       ),
 
       add_attributes: {},
@@ -159,6 +165,7 @@ class Sanitize
       transformers: [
         CLASS_WHITELIST_TRANSFORMER,
         IMG_TAG_TRANSFORMER,
+        TRANSLATE_TRANSFORMER,
         UNSUPPORTED_HREF_TRANSFORMER,
         LINK_REL_TRANSFORMER,
         LINK_TARGET_TRANSFORMER,
diff --git a/spec/controllers/admin/change_emails_controller_spec.rb b/spec/controllers/admin/change_emails_controller_spec.rb
index 503862a7b9..dd8a764b64 100644
--- a/spec/controllers/admin/change_emails_controller_spec.rb
+++ b/spec/controllers/admin/change_emails_controller_spec.rb
@@ -23,7 +23,8 @@ RSpec.describe Admin::ChangeEmailsController do
 
   describe 'GET #update' do
     before do
-      allow(UserMailer).to receive(:confirmation_instructions).and_return(double('email', deliver_later: nil))
+      allow(UserMailer).to receive(:confirmation_instructions)
+        .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
     end
 
     it 'returns http success' do
diff --git a/spec/controllers/admin/confirmations_controller_spec.rb b/spec/controllers/admin/confirmations_controller_spec.rb
index 181616a66e..9559160786 100644
--- a/spec/controllers/admin/confirmations_controller_spec.rb
+++ b/spec/controllers/admin/confirmations_controller_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Admin::ConfirmationsController do
     let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
 
     before do
-      allow(UserMailer).to receive(:confirmation_instructions) { double(:email, deliver_later: nil) }
+      allow(UserMailer).to receive(:confirmation_instructions) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
     end
 
     context 'when email is not confirmed' do
diff --git a/spec/controllers/admin/disputes/appeals_controller_spec.rb b/spec/controllers/admin/disputes/appeals_controller_spec.rb
index 99b19298c6..3c3f23f529 100644
--- a/spec/controllers/admin/disputes/appeals_controller_spec.rb
+++ b/spec/controllers/admin/disputes/appeals_controller_spec.rb
@@ -19,7 +19,8 @@ RSpec.describe Admin::Disputes::AppealsController do
     let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
     before do
-      allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil))
+      allow(UserMailer).to receive(:appeal_approved)
+        .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
       post :approve, params: { id: appeal.id }
     end
 
@@ -40,7 +41,8 @@ RSpec.describe Admin::Disputes::AppealsController do
     let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
     before do
-      allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil))
+      allow(UserMailer).to receive(:appeal_rejected)
+        .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
       post :reject, params: { id: appeal.id }
     end
 
diff --git a/spec/controllers/admin/domain_allows_controller_spec.rb b/spec/controllers/admin/domain_allows_controller_spec.rb
index 6b0453476a..6f82f322b5 100644
--- a/spec/controllers/admin/domain_allows_controller_spec.rb
+++ b/spec/controllers/admin/domain_allows_controller_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Admin::DomainAllowsController do
 
   describe 'DELETE #destroy' do
     it 'disallows the domain' do
-      service = double(call: true)
+      service = instance_double(UnallowDomainService, call: true)
       allow(UnallowDomainService).to receive(:new).and_return(service)
       domain_allow = Fabricate(:domain_allow)
       delete :destroy, params: { id: domain_allow.id }
diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb
index d499aa64ce..fb7fb2957f 100644
--- a/spec/controllers/admin/domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/domain_blocks_controller_spec.rb
@@ -213,7 +213,7 @@ RSpec.describe Admin::DomainBlocksController do
 
   describe 'DELETE #destroy' do
     it 'unblocks the domain' do
-      service = double(call: true)
+      service = instance_double(UnblockDomainService, call: true)
       allow(UnblockDomainService).to receive(:new).and_return(service)
       domain_block = Fabricate(:domain_block)
       delete :destroy, params: { id: domain_block.id }
diff --git a/spec/controllers/admin/reports/actions_controller_spec.rb b/spec/controllers/admin/reports/actions_controller_spec.rb
index 701855f92e..1f3951516d 100644
--- a/spec/controllers/admin/reports/actions_controller_spec.rb
+++ b/spec/controllers/admin/reports/actions_controller_spec.rb
@@ -62,17 +62,10 @@ describe Admin::Reports::ActionsController do
     end
 
     shared_examples 'common behavior' do
-      it 'closes the report' do
-        expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
-      end
+      it 'closes the report and redirects' do
+        expect { subject }.to mark_report_action_taken.and create_target_account_strike
 
-      it 'creates a strike with the expected text' do
-        expect { subject }.to change { report.target_account.strikes.count }.by(1)
         expect(report.target_account.strikes.last.text).to eq text
-      end
-
-      it 'redirects' do
-        subject
         expect(response).to redirect_to(admin_reports_path)
       end
 
@@ -81,20 +74,21 @@ describe Admin::Reports::ActionsController do
           { report_id: report.id }
         end
 
-        it 'closes the report' do
-          expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
-        end
+        it 'closes the report and redirects' do
+          expect { subject }.to mark_report_action_taken.and create_target_account_strike
 
-        it 'creates a strike with the expected text' do
-          expect { subject }.to change { report.target_account.strikes.count }.by(1)
           expect(report.target_account.strikes.last.text).to eq ''
-        end
-
-        it 'redirects' do
-          subject
           expect(response).to redirect_to(admin_reports_path)
         end
       end
+
+      def mark_report_action_taken
+        change { report.reload.action_taken? }.from(false).to(true)
+      end
+
+      def create_target_account_strike
+        change { report.target_account.strikes.count }.by(1)
+      end
     end
 
     shared_examples 'all action types' do
diff --git a/spec/controllers/admin/webhooks_controller_spec.rb b/spec/controllers/admin/webhooks_controller_spec.rb
index 5e45c74082..0ccfbbcc6e 100644
--- a/spec/controllers/admin/webhooks_controller_spec.rb
+++ b/spec/controllers/admin/webhooks_controller_spec.rb
@@ -48,7 +48,7 @@ describe Admin::WebhooksController do
   end
 
   context 'with an existing record' do
-    let!(:webhook) { Fabricate :webhook }
+    let!(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
 
     describe 'GET #show' do
       it 'returns http success and renders view' do
@@ -82,7 +82,7 @@ describe Admin::WebhooksController do
         end.to_not change(webhook, :url)
 
         expect(response).to have_http_status(:success)
-        expect(response).to render_template(:show)
+        expect(response).to render_template(:edit)
       end
     end
 
diff --git a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
deleted file mode 100644
index 523350e123..0000000000
--- a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Api::V1::Admin::AccountActionsController do
-  render_views
-
-  let(:role)   { UserRole.find_by(name: 'Moderator') }
-  let(:user)   { Fabricate(:user, role: role) }
-  let(:scopes) { 'admin:read admin:write' }
-  let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
-  let(:account) { Fabricate(:account) }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'POST #create' do
-    context 'with type of disable' do
-      before do
-        post :create, params: { account_id: account.id, type: 'disable' }
-      end
-
-      it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-      it_behaves_like 'forbidden for wrong role', ''
-
-      it 'returns http success' do
-        expect(response).to have_http_status(200)
-      end
-
-      it 'performs action against account' do
-        expect(account.reload.user_disabled?).to be true
-      end
-
-      it 'logs action' do
-        log_item = Admin::ActionLog.last
-
-        expect(log_item).to_not be_nil
-        expect(log_item.action).to eq :disable
-        expect(log_item.account_id).to eq user.account_id
-        expect(log_item.target_id).to eq account.user.id
-      end
-    end
-
-    context 'with no type' do
-      before do
-        post :create, params: { account_id: account.id }
-      end
-
-      it 'returns http unprocessable entity' do
-        expect(response).to have_http_status(422)
-      end
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/conversations_controller_spec.rb b/spec/controllers/api/v1/conversations_controller_spec.rb
index f8a5985634..28d7c7f3ae 100644
--- a/spec/controllers/api/v1/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/conversations_controller_spec.rb
@@ -18,6 +18,7 @@ RSpec.describe Api::V1::ConversationsController do
 
     before do
       PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct')
+      PostStatusService.new.call(user.account, text: 'Hey, nobody here', visibility: 'direct')
     end
 
     it 'returns http success' do
@@ -33,7 +34,8 @@ RSpec.describe Api::V1::ConversationsController do
     it 'returns conversations' do
       get :index
       json = body_as_json
-      expect(json.size).to eq 1
+      expect(json.size).to eq 2
+      expect(json[0][:accounts].size).to eq 1
     end
 
     context 'with since_id' do
@@ -41,7 +43,7 @@ RSpec.describe Api::V1::ConversationsController do
         it 'returns conversations' do
           get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) }
           json = body_as_json
-          expect(json.size).to eq 1
+          expect(json.size).to eq 2
         end
       end
 
diff --git a/spec/controllers/api/v1/notifications_controller_spec.rb b/spec/controllers/api/v1/notifications_controller_spec.rb
index 28b8e656ab..6615848b83 100644
--- a/spec/controllers/api/v1/notifications_controller_spec.rb
+++ b/spec/controllers/api/v1/notifications_controller_spec.rb
@@ -67,24 +67,13 @@ RSpec.describe Api::V1::NotificationsController do
         get :index
       end
 
-      it 'returns http success' do
+      it 'returns expected notification types', :aggregate_failures do
         expect(response).to have_http_status(200)
-      end
 
-      it 'includes reblog' do
-        expect(body_as_json.pluck(:type)).to include 'reblog'
-      end
-
-      it 'includes mention' do
-        expect(body_as_json.pluck(:type)).to include 'mention'
-      end
-
-      it 'includes favourite' do
-        expect(body_as_json.pluck(:type)).to include 'favourite'
-      end
-
-      it 'includes follow' do
-        expect(body_as_json.pluck(:type)).to include 'follow'
+        expect(body_json_types).to include 'reblog'
+        expect(body_json_types).to include 'mention'
+        expect(body_json_types).to include 'favourite'
+        expect(body_json_types).to include 'follow'
       end
     end
 
@@ -93,12 +82,14 @@ RSpec.describe Api::V1::NotificationsController do
         get :index, params: { account_id: third.account.id }
       end
 
-      it 'returns http success' do
+      it 'returns only notifications from specified user', :aggregate_failures do
         expect(response).to have_http_status(200)
+
+        expect(body_json_account_ids.uniq).to eq [third.account.id.to_s]
       end
 
-      it 'returns only notifications from specified user' do
-        expect(body_as_json.map { |x| x[:account][:id] }.uniq).to eq [third.account.id.to_s]
+      def body_json_account_ids
+        body_as_json.map { |x| x[:account][:id] }
       end
     end
 
@@ -107,27 +98,23 @@ RSpec.describe Api::V1::NotificationsController do
         get :index, params: { account_id: 'foo' }
       end
 
-      it 'returns http success' do
+      it 'returns nothing', :aggregate_failures do
         expect(response).to have_http_status(200)
-      end
 
-      it 'returns nothing' do
         expect(body_as_json.size).to eq 0
       end
     end
 
-    describe 'with excluded_types param' do
+    describe 'with exclude_types param' do
       before do
         get :index, params: { exclude_types: %w(mention) }
       end
 
-      it 'returns http success' do
+      it 'returns everything but excluded type', :aggregate_failures do
         expect(response).to have_http_status(200)
-      end
 
-      it 'returns everything but excluded type' do
         expect(body_as_json.size).to_not eq 0
-        expect(body_as_json.pluck(:type).uniq).to_not include 'mention'
+        expect(body_json_types.uniq).to_not include 'mention'
       end
     end
 
@@ -136,13 +123,15 @@ RSpec.describe Api::V1::NotificationsController do
         get :index, params: { types: %w(mention) }
       end
 
-      it 'returns http success' do
+      it 'returns only requested type', :aggregate_failures do
         expect(response).to have_http_status(200)
-      end
 
-      it 'returns only requested type' do
-        expect(body_as_json.pluck(:type).uniq).to eq ['mention']
+        expect(body_json_types.uniq).to eq ['mention']
       end
     end
+
+    def body_json_types
+      body_as_json.pluck(:type)
+    end
   end
 end
diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb
index 0eb9ce1709..01b7e4a71c 100644
--- a/spec/controllers/api/v1/reports_controller_spec.rb
+++ b/spec/controllers/api/v1/reports_controller_spec.rb
@@ -23,7 +23,8 @@ RSpec.describe Api::V1::ReportsController do
     let(:rule_ids) { nil }
 
     before do
-      allow(AdminMailer).to receive(:new_report).and_return(double('email', deliver_later: nil))
+      allow(AdminMailer).to receive(:new_report)
+        .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
       post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward }
     end
 
diff --git a/spec/controllers/api/v1/statuses/histories_controller_spec.rb b/spec/controllers/api/v1/statuses/histories_controller_spec.rb
index 00677f1d2c..99384c8ed5 100644
--- a/spec/controllers/api/v1/statuses/histories_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses/histories_controller_spec.rb
@@ -23,6 +23,7 @@ describe Api::V1::Statuses::HistoriesController do
 
       it 'returns http success' do
         expect(response).to have_http_status(200)
+        expect(body_as_json.size).to_not be 0
       end
     end
   end
diff --git a/spec/controllers/api/v1/suggestions_controller_spec.rb b/spec/controllers/api/v1/suggestions_controller_spec.rb
deleted file mode 100644
index c61ce0ec05..0000000000
--- a/spec/controllers/api/v1/suggestions_controller_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Api::V1::SuggestionsController do
-  render_views
-
-  let(:user)  { Fabricate(:user) }
-  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'GET #index' do
-    let(:bob) { Fabricate(:account) }
-    let(:jeff) { Fabricate(:account) }
-
-    before do
-      PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
-      PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
-
-      get :index
-    end
-
-    it 'returns http success' do
-      expect(response).to have_http_status(200)
-    end
-
-    it 'returns accounts' do
-      json = body_as_json
-
-      expect(json.size).to be >= 1
-      expect(json.pluck(:id)).to include(*[bob, jeff].map { |i| i.id.to_s })
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/tags_controller_spec.rb b/spec/controllers/api/v1/tags_controller_spec.rb
deleted file mode 100644
index e914f5992d..0000000000
--- a/spec/controllers/api/v1/tags_controller_spec.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Api::V1::TagsController do
-  render_views
-
-  let(:user)   { Fabricate(:user) }
-  let(:scopes) { 'write:follows' }
-  let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
-
-  before { allow(controller).to receive(:doorkeeper_token) { token } }
-
-  describe 'GET #show' do
-    before do
-      get :show, params: { id: name }
-    end
-
-    context 'with existing tag' do
-      let!(:tag) { Fabricate(:tag) }
-      let(:name) { tag.name }
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-    end
-
-    context 'with non-existing tag' do
-      let(:name) { 'hoge' }
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-    end
-  end
-
-  describe 'POST #follow' do
-    let!(:unrelated_tag) { Fabricate(:tag) }
-
-    before do
-      TagFollow.create!(account: user.account, tag: unrelated_tag)
-
-      post :follow, params: { id: name }
-    end
-
-    context 'with existing tag' do
-      let!(:tag) { Fabricate(:tag) }
-      let(:name) { tag.name }
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'creates follow' do
-        expect(TagFollow.where(tag: tag, account: user.account).exists?).to be true
-      end
-    end
-
-    context 'with non-existing tag' do
-      let(:name) { 'hoge' }
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'creates follow' do
-        expect(TagFollow.where(tag: Tag.find_by!(name: name), account: user.account).exists?).to be true
-      end
-    end
-  end
-
-  describe 'POST #unfollow' do
-    let!(:tag) { Fabricate(:tag, name: 'foo') }
-    let!(:tag_follow) { Fabricate(:tag_follow, account: user.account, tag: tag) }
-
-    before do
-      post :unfollow, params: { id: tag.name }
-    end
-
-    it 'returns http success' do
-      expect(response).to have_http_status(:success)
-    end
-
-    it 'removes the follow' do
-      expect(TagFollow.where(tag: tag, account: user.account).exists?).to be false
-    end
-  end
-end
diff --git a/spec/controllers/api/v2/admin/accounts_controller_spec.rb b/spec/controllers/api/v2/admin/accounts_controller_spec.rb
index a775be1709..635f645915 100644
--- a/spec/controllers/api/v2/admin/accounts_controller_spec.rb
+++ b/spec/controllers/api/v2/admin/accounts_controller_spec.rb
@@ -55,5 +55,13 @@ RSpec.describe Api::V2::Admin::AccountsController do
         end
       end
     end
+
+    context 'with limit param' do
+      let(:params) { { limit: 1 } }
+
+      it 'sets the correct pagination headers' do
+        expect(response.headers['Link'].find_link(%w(rel next)).href).to eq api_v2_admin_accounts_url(limit: 1, max_id: admin_account.id)
+      end
+    end
   end
 end
diff --git a/spec/controllers/api/web/embeds_controller_spec.rb b/spec/controllers/api/web/embeds_controller_spec.rb
index b0c48a5aed..8c4e1a8f26 100644
--- a/spec/controllers/api/web/embeds_controller_spec.rb
+++ b/spec/controllers/api/web/embeds_controller_spec.rb
@@ -26,7 +26,7 @@ describe Api::Web::EmbedsController do
 
     context 'when fails to find status' do
       let(:url) { 'https://host.test/oembed.html' }
-      let(:service_instance) { double('fetch_oembed_service') }
+      let(:service_instance) { instance_double(FetchOEmbedService) }
 
       before do
         allow(FetchOEmbedService).to receive(:new) { service_instance }
diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb
index 5b7d5d5cd4..c727a76333 100644
--- a/spec/controllers/auth/sessions_controller_spec.rb
+++ b/spec/controllers/auth/sessions_controller_spec.rb
@@ -127,7 +127,8 @@ RSpec.describe Auth::SessionsController do
 
         before do
           allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return(current_ip)
-          allow(UserMailer).to receive(:suspicious_sign_in).and_return(double('email', deliver_later!: nil))
+          allow(UserMailer).to receive(:suspicious_sign_in)
+            .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later!: nil))
           user.update(current_sign_in_at: 1.month.ago)
           post :create, params: { user: { email: user.email, password: user.password } }
         end
diff --git a/spec/controllers/authorize_interactions_controller_spec.rb b/spec/controllers/authorize_interactions_controller_spec.rb
index e521039410..098c25ba32 100644
--- a/spec/controllers/authorize_interactions_controller_spec.rb
+++ b/spec/controllers/authorize_interactions_controller_spec.rb
@@ -28,7 +28,7 @@ describe AuthorizeInteractionsController do
       end
 
       it 'renders error when account cant be found' do
-        service = double
+        service = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(service)
         allow(service).to receive(:call).with('missing@hostname').and_return(nil)
 
@@ -40,7 +40,7 @@ describe AuthorizeInteractionsController do
 
       it 'sets resource from url' do
         account = Fabricate(:account)
-        service = double
+        service = instance_double(ResolveURLService)
         allow(ResolveURLService).to receive(:new).and_return(service)
         allow(service).to receive(:call).with('http://example.com').and_return(account)
 
@@ -52,7 +52,7 @@ describe AuthorizeInteractionsController do
 
       it 'sets resource from acct uri' do
         account = Fabricate(:account)
-        service = double
+        service = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(service)
         allow(service).to receive(:call).with('found@hostname').and_return(account)
 
@@ -82,7 +82,7 @@ describe AuthorizeInteractionsController do
       end
 
       it 'shows error when account not found' do
-        service = double
+        service = instance_double(ResolveAccountService)
 
         allow(ResolveAccountService).to receive(:new).and_return(service)
         allow(service).to receive(:call).with('user@hostname').and_return(nil)
@@ -94,7 +94,7 @@ describe AuthorizeInteractionsController do
 
       it 'follows account when found' do
         target_account = Fabricate(:account)
-        service = double
+        service = instance_double(ResolveAccountService)
 
         allow(ResolveAccountService).to receive(:new).and_return(service)
         allow(service).to receive(:call).with('user@hostname').and_return(target_account)
diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb
index d0e1cd3908..a0f9c7b910 100644
--- a/spec/controllers/disputes/appeals_controller_spec.rb
+++ b/spec/controllers/disputes/appeals_controller_spec.rb
@@ -14,7 +14,8 @@ RSpec.describe Disputes::AppealsController do
     let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
 
     before do
-      allow(AdminMailer).to receive(:new_appeal).and_return(double('email', deliver_later: nil))
+      allow(AdminMailer).to receive(:new_appeal)
+        .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
       post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } }
     end
 
diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb
index 1885814cda..bd98929c02 100644
--- a/spec/controllers/statuses_controller_spec.rb
+++ b/spec/controllers/statuses_controller_spec.rb
@@ -75,23 +75,11 @@ describe StatusesController do
       context 'with HTML' do
         let(:format) { 'html' }
 
-        it 'returns http success' do
+        it 'renders status successfully', :aggregate_failures do
           expect(response).to have_http_status(200)
-        end
-
-        it 'returns Link header' do
           expect(response.headers['Link'].to_s).to include 'activity+json'
-        end
-
-        it 'returns Vary header' do
           expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-        end
-
-        it 'returns public Cache-Control header' do
           expect(response.headers['Cache-Control']).to include 'public'
-        end
-
-        it 'renders status' do
           expect(response).to render_template(:show)
           expect(response.body).to include status.text
         end
@@ -100,25 +88,13 @@ describe StatusesController do
       context 'with JSON' do
         let(:format) { 'json' }
 
-        it 'returns http success' do
-          expect(response).to have_http_status(200)
-        end
-
-        it 'returns Link header' do
-          expect(response.headers['Link'].to_s).to include 'activity+json'
-        end
-
-        it 'returns Vary header' do
-          expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-        end
-
         it_behaves_like 'cacheable response'
 
-        it 'returns Content-Type header' do
+        it 'renders ActivityPub Note object successfully', :aggregate_failures do
+          expect(response).to have_http_status(200)
+          expect(response.headers['Link'].to_s).to include 'activity+json'
+          expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
           expect(response.headers['Content-Type']).to include 'application/activity+json'
-        end
-
-        it 'renders ActivityPub Note object' do
           json = body_as_json
           expect(json[:content]).to include status.text
         end
@@ -199,23 +175,11 @@ describe StatusesController do
         context 'with HTML' do
           let(:format) { 'html' }
 
-          it 'returns http success' do
+          it 'renders status successfully', :aggregate_failures do
             expect(response).to have_http_status(200)
-          end
-
-          it 'returns Link header' do
             expect(response.headers['Link'].to_s).to include 'activity+json'
-          end
-
-          it 'returns Vary header' do
             expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-          end
-
-          it 'returns private Cache-Control header' do
             expect(response.headers['Cache-Control']).to include 'private'
-          end
-
-          it 'renders status' do
             expect(response).to render_template(:show)
             expect(response.body).to include status.text
           end
@@ -224,27 +188,12 @@ describe StatusesController do
         context 'with JSON' do
           let(:format) { 'json' }
 
-          it 'returns http success' do
+          it 'renders ActivityPub Note object successfully', :aggregate_failures do
             expect(response).to have_http_status(200)
-          end
-
-          it 'returns Link header' do
             expect(response.headers['Link'].to_s).to include 'activity+json'
-          end
-
-          it 'returns Vary header' do
             expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-          end
-
-          it 'returns private Cache-Control header' do
             expect(response.headers['Cache-Control']).to include 'private'
-          end
-
-          it 'returns Content-Type header' do
             expect(response.headers['Content-Type']).to include 'application/activity+json'
-          end
-
-          it 'renders ActivityPub Note object' do
             json = body_as_json
             expect(json[:content]).to include status.text
           end
@@ -263,23 +212,11 @@ describe StatusesController do
           context 'with HTML' do
             let(:format) { 'html' }
 
-            it 'returns http success' do
+            it 'renders status successfully', :aggregate_failures do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'renders status' do
               expect(response).to render_template(:show)
               expect(response.body).to include status.text
             end
@@ -288,27 +225,12 @@ describe StatusesController do
           context 'with JSON' do
             let(:format) { 'json' }
 
-            it 'returns http success' do
+            it 'renders ActivityPub Note object successfully', :aggregate_failures do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'returns Content-Type header' do
               expect(response.headers['Content-Type']).to include 'application/activity+json'
-            end
-
-            it 'renders ActivityPub Note object' do
               json = body_as_json
               expect(json[:content]).to include status.text
             end
@@ -350,23 +272,11 @@ describe StatusesController do
           context 'with HTML' do
             let(:format) { 'html' }
 
-            it 'returns http success' do
+            it 'renders status successfully', :aggregate_failures do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'renders status' do
               expect(response).to render_template(:show)
               expect(response.body).to include status.text
             end
@@ -375,27 +285,12 @@ describe StatusesController do
           context 'with JSON' do
             let(:format) { 'json' }
 
-            it 'returns http success' do
+            it 'renders ActivityPub Note object successfully' do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'returns Content-Type header' do
               expect(response.headers['Content-Type']).to include 'application/activity+json'
-            end
-
-            it 'renders ActivityPub Note object' do
               json = body_as_json
               expect(json[:content]).to include status.text
             end
@@ -463,23 +358,11 @@ describe StatusesController do
         context 'with HTML' do
           let(:format) { 'html' }
 
-          it 'returns http success' do
+          it 'renders status successfully', :aggregate_failures do
             expect(response).to have_http_status(200)
-          end
-
-          it 'returns Link header' do
             expect(response.headers['Link'].to_s).to include 'activity+json'
-          end
-
-          it 'returns Vary header' do
             expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-          end
-
-          it 'returns private Cache-Control header' do
             expect(response.headers['Cache-Control']).to include 'private'
-          end
-
-          it 'renders status' do
             expect(response).to render_template(:show)
             expect(response.body).to include status.text
           end
@@ -488,25 +371,13 @@ describe StatusesController do
         context 'with JSON' do
           let(:format) { 'json' }
 
-          it 'returns http success' do
-            expect(response).to have_http_status(200)
-          end
-
-          it 'returns Link header' do
-            expect(response.headers['Link'].to_s).to include 'activity+json'
-          end
-
-          it 'returns Vary header' do
-            expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-          end
-
           it_behaves_like 'cacheable response'
 
-          it 'returns Content-Type header' do
+          it 'renders ActivityPub Note object successfully', :aggregate_failures do
+            expect(response).to have_http_status(200)
+            expect(response.headers['Link'].to_s).to include 'activity+json'
+            expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
             expect(response.headers['Content-Type']).to include 'application/activity+json'
-          end
-
-          it 'renders ActivityPub Note object' do
             json = body_as_json
             expect(json[:content]).to include status.text
           end
@@ -525,23 +396,11 @@ describe StatusesController do
           context 'with HTML' do
             let(:format) { 'html' }
 
-            it 'returns http success' do
+            it 'renders status successfully', :aggregate_failures do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'renders status' do
               expect(response).to render_template(:show)
               expect(response.body).to include status.text
             end
@@ -550,27 +409,12 @@ describe StatusesController do
           context 'with JSON' do
             let(:format) { 'json' }
 
-            it 'returns http success' do
+            it 'renders ActivityPub Note object successfully' do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'returns Content-Type header' do
               expect(response.headers['Content-Type']).to include 'application/activity+json'
-            end
-
-            it 'renders ActivityPub Note object' do
               json = body_as_json
               expect(json[:content]).to include status.text
             end
@@ -612,23 +456,11 @@ describe StatusesController do
           context 'with HTML' do
             let(:format) { 'html' }
 
-            it 'returns http success' do
+            it 'renders status successfully', :aggregate_failures do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'renders status' do
               expect(response).to render_template(:show)
               expect(response.body).to include status.text
             end
@@ -637,27 +469,12 @@ describe StatusesController do
           context 'with JSON' do
             let(:format) { 'json' }
 
-            it 'returns http success' do
+            it 'renders ActivityPub Note object', :aggregate_failures do
               expect(response).to have_http_status(200)
-            end
-
-            it 'returns Link header' do
               expect(response.headers['Link'].to_s).to include 'activity+json'
-            end
-
-            it 'returns Vary header' do
               expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-            end
-
-            it 'returns private Cache-Control header' do
               expect(response.headers['Cache-Control']).to include 'private'
-            end
-
-            it 'returns Content-Type header' do
               expect(response.headers['Content-Type']).to include 'application/activity+json'
-            end
-
-            it 'renders ActivityPub Note object' do
               json = body_as_json
               expect(json[:content]).to include status.text
             end
@@ -933,23 +750,11 @@ describe StatusesController do
         get :embed, params: { account_username: status.account.username, id: status.id }
       end
 
-      it 'returns http success' do
+      it 'renders status successfully', :aggregate_failures do
         expect(response).to have_http_status(200)
-      end
-
-      it 'returns Link header' do
         expect(response.headers['Link'].to_s).to include 'activity+json'
-      end
-
-      it 'returns Vary header' do
         expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
-      end
-
-      it 'returns public Cache-Control header' do
         expect(response.headers['Cache-Control']).to include 'public'
-      end
-
-      it 'renders status' do
         expect(response).to render_template(:embed)
         expect(response.body).to include status.text
       end
diff --git a/spec/helpers/statuses_helper_spec.rb b/spec/helpers/statuses_helper_spec.rb
index 105da7e1b1..b7824ca604 100644
--- a/spec/helpers/statuses_helper_spec.rb
+++ b/spec/helpers/statuses_helper_spec.rb
@@ -117,42 +117,42 @@ describe StatusesHelper do
 
   describe '#style_classes' do
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       classes = helper.style_classes(status, false, false, false)
 
       expect(classes).to eq 'entry'
     end
 
     it do
-      status = double(reblog?: true)
+      status = instance_double(Status, reblog?: true)
       classes = helper.style_classes(status, false, false, false)
 
       expect(classes).to eq 'entry entry-reblog'
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       classes = helper.style_classes(status, true, false, false)
 
       expect(classes).to eq 'entry entry-predecessor'
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       classes = helper.style_classes(status, false, true, false)
 
       expect(classes).to eq 'entry entry-successor'
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       classes = helper.style_classes(status, false, false, true)
 
       expect(classes).to eq 'entry entry-center'
     end
 
     it do
-      status = double(reblog?: true)
+      status = instance_double(Status, reblog?: true)
       classes = helper.style_classes(status, true, true, true)
 
       expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center'
@@ -161,35 +161,35 @@ describe StatusesHelper do
 
   describe '#microformats_classes' do
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       classes = helper.microformats_classes(status, false, false)
 
       expect(classes).to eq ''
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       classes = helper.microformats_classes(status, true, false)
 
       expect(classes).to eq 'p-in-reply-to'
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       classes = helper.microformats_classes(status, false, true)
 
       expect(classes).to eq 'p-comment'
     end
 
     it do
-      status = double(reblog?: true)
+      status = instance_double(Status, reblog?: true)
       classes = helper.microformats_classes(status, true, false)
 
       expect(classes).to eq 'p-in-reply-to p-repost-of'
     end
 
     it do
-      status = double(reblog?: true)
+      status = instance_double(Status, reblog?: true)
       classes = helper.microformats_classes(status, true, true)
 
       expect(classes).to eq 'p-in-reply-to p-repost-of p-comment'
@@ -198,42 +198,42 @@ describe StatusesHelper do
 
   describe '#microformats_h_class' do
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       css_class = helper.microformats_h_class(status, false, false, false)
 
       expect(css_class).to eq 'h-entry'
     end
 
     it do
-      status = double(reblog?: true)
+      status = instance_double(Status, reblog?: true)
       css_class = helper.microformats_h_class(status, false, false, false)
 
       expect(css_class).to eq 'h-cite'
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       css_class = helper.microformats_h_class(status, true, false, false)
 
       expect(css_class).to eq 'h-cite'
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       css_class = helper.microformats_h_class(status, false, true, false)
 
       expect(css_class).to eq 'h-cite'
     end
 
     it do
-      status = double(reblog?: false)
+      status = instance_double(Status, reblog?: false)
       css_class = helper.microformats_h_class(status, false, false, true)
 
       expect(css_class).to eq ''
     end
 
     it do
-      status = double(reblog?: true)
+      status = instance_double(Status, reblog?: true)
       css_class = helper.microformats_h_class(status, true, true, true)
 
       expect(css_class).to eq 'h-cite'
diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb
index 9c45e465e4..ec6df01716 100644
--- a/spec/lib/activitypub/activity/add_spec.rb
+++ b/spec/lib/activitypub/activity/add_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Add do
     end
 
     context 'when status was not known before' do
-      let(:service_stub) { double }
+      let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) }
 
       let(:json) do
         {
diff --git a/spec/lib/activitypub/activity/move_spec.rb b/spec/lib/activitypub/activity/move_spec.rb
index 8bd23aa7bf..f3973c70ce 100644
--- a/spec/lib/activitypub/activity/move_spec.rb
+++ b/spec/lib/activitypub/activity/move_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Move do
     stub_request(:post, old_account.inbox_url).to_return(status: 200)
     stub_request(:post, new_account.inbox_url).to_return(status: 200)
 
-    service_stub = double
+    service_stub = instance_double(ActivityPub::FetchRemoteAccountService)
     allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_stub)
     allow(service_stub).to receive(:call).and_return(returned_account)
   end
diff --git a/spec/lib/request_pool_spec.rb b/spec/lib/request_pool_spec.rb
index 395268fe43..f179e6ca94 100644
--- a/spec/lib/request_pool_spec.rb
+++ b/spec/lib/request_pool_spec.rb
@@ -48,16 +48,25 @@ describe RequestPool do
       expect(subject.size).to be > 1
     end
 
-    it 'closes idle connections' do
-      stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!')
-
-      subject.with('http://example.com') do |http_client|
-        http_client.get('/').flush
+    context 'with an idle connection' do
+      before do
+        stub_const('RequestPool::MAX_IDLE_TIME', 1) # Lower idle time limit to 1 seconds
+        stub_const('RequestPool::REAPER_FREQUENCY', 0.1) # Run reaper every 0.1 seconds
+        stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!')
       end
 
-      expect(subject.size).to eq 1
-      sleep RequestPool::MAX_IDLE_TIME + 30 + 1
-      expect(subject.size).to eq 0
+      it 'closes the connections' do
+        subject.with('http://example.com') do |http_client|
+          http_client.get('/').flush
+        end
+
+        expect { reaper_observes_idle_timeout }.to change(subject, :size).from(1).to(0)
+      end
+
+      def reaper_observes_idle_timeout
+        # One full idle period and 2 reaper cycles more
+        sleep RequestPool::MAX_IDLE_TIME + (RequestPool::REAPER_FREQUENCY * 2)
+      end
     end
   end
 end
diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb
index e88631e475..f0861376b9 100644
--- a/spec/lib/request_spec.rb
+++ b/spec/lib/request_spec.rb
@@ -48,7 +48,7 @@ describe Request do
       end
 
       it 'executes a HTTP request when the first address is private' do
-        resolver = double
+        resolver = instance_double(Resolv::DNS)
 
         allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844))
         allow(resolver).to receive(:timeouts=).and_return(nil)
@@ -83,7 +83,7 @@ describe Request do
       end
 
       it 'raises Mastodon::ValidationError' do
-        resolver = double
+        resolver = instance_double(Resolv::DNS)
 
         allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face))
         allow(resolver).to receive(:timeouts=).and_return(nil)
diff --git a/spec/lib/sanitize_config_spec.rb b/spec/lib/sanitize_config_spec.rb
index 586a43d594..cc9916bfd4 100644
--- a/spec/lib/sanitize_config_spec.rb
+++ b/spec/lib/sanitize_config_spec.rb
@@ -36,6 +36,14 @@ describe Sanitize::Config do
       expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
     end
 
+    it 'keeps a with translate="no"' do
+      expect(Sanitize.fragment('<a href="http://example.com" translate="no">Test</a>', subject)).to eq '<a href="http://example.com" translate="no" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
+    end
+
+    it 'removes "translate" attribute with invalid value' do
+      expect(Sanitize.fragment('<a href="http://example.com" translate="foo">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
+    end
+
     it 'removes a with unparsable href' do
       expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test'
     end
diff --git a/spec/lib/suspicious_sign_in_detector_spec.rb b/spec/lib/suspicious_sign_in_detector_spec.rb
index c61b1ef1e6..9e64aff08a 100644
--- a/spec/lib/suspicious_sign_in_detector_spec.rb
+++ b/spec/lib/suspicious_sign_in_detector_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe SuspiciousSignInDetector do
     subject { described_class.new(user).suspicious?(request) }
 
     let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) }
-    let(:request) { double(remote_ip: remote_ip) }
+    let(:request) { instance_double(ActionDispatch::Request, remote_ip: remote_ip) }
     let(:remote_ip) { nil }
 
     context 'when user has 2FA enabled' do
diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb
index 702aa1c354..3c42a2bb7a 100644
--- a/spec/mailers/user_mailer_spec.rb
+++ b/spec/mailers/user_mailer_spec.rb
@@ -142,4 +142,59 @@ describe UserMailer do
       expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title')
     end
   end
+
+  describe 'two_factor_enabled' do
+    let(:mail) { described_class.two_factor_enabled(receiver) }
+
+    it 'renders two_factor_enabled mail' do
+      expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_enabled.subject')
+      expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_enabled.explanation')
+    end
+  end
+
+  describe 'two_factor_disabled' do
+    let(:mail) { described_class.two_factor_disabled(receiver) }
+
+    it 'renders two_factor_disabled mail' do
+      expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_disabled.subject')
+      expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_disabled.explanation')
+    end
+  end
+
+  describe 'webauthn_enabled' do
+    let(:mail) { described_class.webauthn_enabled(receiver) }
+
+    it 'renders webauthn_enabled mail' do
+      expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_enabled.subject')
+      expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_enabled.explanation')
+    end
+  end
+
+  describe 'webauthn_disabled' do
+    let(:mail) { described_class.webauthn_disabled(receiver) }
+
+    it 'renders webauthn_disabled mail' do
+      expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_disabled.subject')
+      expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_disabled.explanation')
+    end
+  end
+
+  describe 'two_factor_recovery_codes_changed' do
+    let(:mail) { described_class.two_factor_recovery_codes_changed(receiver) }
+
+    it 'renders two_factor_recovery_codes_changed mail' do
+      expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')
+      expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation')
+    end
+  end
+
+  describe 'webauthn_credential_added' do
+    let(:credential) { Fabricate.build(:webauthn_credential) }
+    let(:mail) { described_class.webauthn_credential_added(receiver, credential) }
+
+    it 'renders webauthn_credential_added mail' do
+      expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_credential.added.subject')
+      expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_credential.added.explanation')
+    end
+  end
 end
diff --git a/spec/models/account/field_spec.rb b/spec/models/account/field_spec.rb
index 5715a53791..22593bb218 100644
--- a/spec/models/account/field_spec.rb
+++ b/spec/models/account/field_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Account::Field do
   describe '#verified?' do
     subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) }
 
-    let(:account) { double('Account', local?: true) }
+    let(:account) { instance_double(Account, local?: true) }
 
     context 'when verified_at is set' do
       let(:verified_at) { Time.now.utc.iso8601 }
@@ -28,7 +28,7 @@ RSpec.describe Account::Field do
   describe '#mark_verified!' do
     subject { described_class.new(account, original_hash) }
 
-    let(:account) { double('Account', local?: true) }
+    let(:account) { instance_double(Account, local?: true) }
     let(:original_hash) { { 'name' => 'Foo', 'value' => 'Bar' } }
 
     before do
@@ -47,7 +47,7 @@ RSpec.describe Account::Field do
   describe '#verifiable?' do
     subject { described_class.new(account, 'name' => 'Foo', 'value' => value) }
 
-    let(:account) { double('Account', local?: local) }
+    let(:account) { instance_double(Account, local?: local) }
 
     context 'with local accounts' do
       let(:local) { true }
diff --git a/spec/models/account_migration_spec.rb b/spec/models/account_migration_spec.rb
index d76edddd51..f4544740b1 100644
--- a/spec/models/account_migration_spec.rb
+++ b/spec/models/account_migration_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe AccountMigration do
       before do
         target_account.aliases.create!(acct: source_account.acct)
 
-        service_double = double
+        service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(service_double)
         allow(service_double).to receive(:call).with(target_acct, anything).and_return(target_account)
       end
@@ -29,7 +29,7 @@ RSpec.describe AccountMigration do
       let(:target_acct) { 'target@remote' }
 
       before do
-        service_double = double
+        service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(service_double)
         allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil)
       end
diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb
index 052a06e5ca..75842e25ba 100644
--- a/spec/models/session_activation_spec.rb
+++ b/spec/models/session_activation_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe SessionActivation do
       allow(session_activation).to receive(:detection).and_return(detection)
     end
 
-    let(:detection)          { double(id: 1) }
+    let(:detection)          { instance_double(Browser::Chrome, id: 1) }
     let(:session_activation) { Fabricate(:session_activation) }
 
     it 'returns detection.id' do
@@ -30,7 +30,7 @@ RSpec.describe SessionActivation do
     end
 
     let(:session_activation) { Fabricate(:session_activation) }
-    let(:detection)          { double(platform: double(id: 1)) }
+    let(:detection)          { instance_double(Browser::Chrome, platform: instance_double(Browser::Platform, id: 1)) }
 
     it 'returns detection.platform.id' do
       expect(session_activation.platform).to be 1
diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb
index bba585cec6..5ed5c5d766 100644
--- a/spec/models/setting_spec.rb
+++ b/spec/models/setting_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe Setting do
 
         context 'when RailsSettings::Settings.object returns truthy' do
           let(:object) { db_val }
-          let(:db_val) { double(value: 'db_val') }
+          let(:db_val) { instance_double(described_class, value: 'db_val') }
 
           context 'when default_value is a Hash' do
             let(:default_value) { { default_value: 'default_value' } }
diff --git a/spec/models/user_settings_spec.rb b/spec/models/user_settings_spec.rb
index f0e4272fd9..653597c90d 100644
--- a/spec/models/user_settings_spec.rb
+++ b/spec/models/user_settings_spec.rb
@@ -49,6 +49,16 @@ RSpec.describe UserSettings do
         expect(subject[:always_send_emails]).to be true
       end
     end
+
+    context 'when the setting has a closed set of values' do
+      it 'updates the attribute when given a valid value' do
+        expect { subject[:'web.display_media'] = :show_all }.to change { subject[:'web.display_media'] }.from('default').to('show_all')
+      end
+
+      it 'raises an error when given an invalid value' do
+        expect { subject[:'web.display_media'] = 'invalid value' }.to raise_error ArgumentError
+      end
+    end
   end
 
   describe '#update' do
diff --git a/spec/policies/webhook_policy_spec.rb b/spec/policies/webhook_policy_spec.rb
index 1eac8932d4..909311461a 100644
--- a/spec/policies/webhook_policy_spec.rb
+++ b/spec/policies/webhook_policy_spec.rb
@@ -8,16 +8,32 @@ describe WebhookPolicy do
   let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
-  permissions :index?, :create?, :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do
+  permissions :index?, :create? do
     context 'with an admin' do
       it 'permits' do
-        expect(policy).to permit(admin, Tag)
+        expect(policy).to permit(admin, Webhook)
       end
     end
 
     context 'with a non-admin' do
       it 'denies' do
-        expect(policy).to_not permit(john, Tag)
+        expect(policy).to_not permit(john, Webhook)
+      end
+    end
+  end
+
+  permissions :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do
+    let(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
+
+    context 'with an admin' do
+      it 'permits' do
+        expect(policy).to permit(admin, webhook)
+      end
+    end
+
+    context 'with a non-admin' do
+      it 'denies' do
+        expect(policy).to_not permit(john, webhook)
       end
     end
   end
diff --git a/spec/requests/api/v1/admin/account_actions_spec.rb b/spec/requests/api/v1/admin/account_actions_spec.rb
new file mode 100644
index 0000000000..9295d262d6
--- /dev/null
+++ b/spec/requests/api/v1/admin/account_actions_spec.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Account actions' do
+  let(:role)    { UserRole.find_by(name: 'Admin') }
+  let(:user)    { Fabricate(:user, role: role) }
+  let(:scopes)  { 'admin:write admin:write:accounts' }
+  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
+  let(:mailer)  { instance_double(ActionMailer::MessageDelivery, deliver_later!: nil) }
+
+  before do
+    allow(UserMailer).to receive(:warning).with(target_account.user, anything).and_return(mailer)
+  end
+
+  shared_examples 'a successful notification delivery' do
+    it 'notifies the user about the action taken' do
+      subject
+
+      expect(UserMailer).to have_received(:warning).with(target_account.user, anything).once
+      expect(mailer).to have_received(:deliver_later!).once
+    end
+  end
+
+  shared_examples 'a successful logged action' do |action_type, target_type|
+    it 'logs action' do
+      subject
+
+      log_item = Admin::ActionLog.last
+
+      expect(log_item).to be_present
+      expect(log_item.action).to eq(action_type)
+      expect(log_item.account_id).to eq(user.account_id)
+      expect(log_item.target_id).to eq(target_type == :user ? target_account.user.id : target_account.id)
+    end
+  end
+
+  describe 'POST /api/v1/admin/accounts/:id/action' do
+    subject do
+      post "/api/v1/admin/accounts/#{target_account.id}/action", headers: headers, params: params
+    end
+
+    let(:target_account) { Fabricate(:account) }
+
+    context 'with type of disable' do
+      let(:params) { { type: 'disable' } }
+
+      it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
+      it_behaves_like 'forbidden for wrong role', ''
+      it_behaves_like 'a successful notification delivery'
+      it_behaves_like 'a successful logged action', :disable, :user
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+
+      it 'disables the target account' do
+        expect { subject }.to change { target_account.reload.user_disabled? }.from(false).to(true)
+      end
+    end
+
+    context 'with type of sensitive' do
+      let(:params) { { type: 'sensitive' } }
+
+      it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
+      it_behaves_like 'forbidden for wrong role', ''
+      it_behaves_like 'a successful notification delivery'
+      it_behaves_like 'a successful logged action', :sensitive, :account
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+
+      it 'marks the target account as sensitive' do
+        expect { subject }.to change { target_account.reload.sensitized? }.from(false).to(true)
+      end
+    end
+
+    context 'with type of silence' do
+      let(:params) { { type: 'silence' } }
+
+      it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
+      it_behaves_like 'forbidden for wrong role', ''
+      it_behaves_like 'a successful notification delivery'
+      it_behaves_like 'a successful logged action', :silence, :account
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+
+      it 'marks the target account as silenced' do
+        expect { subject }.to change { target_account.reload.silenced? }.from(false).to(true)
+      end
+    end
+
+    context 'with type of suspend' do
+      let(:params) { { type: 'suspend' } }
+
+      it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
+      it_behaves_like 'forbidden for wrong role', ''
+      it_behaves_like 'a successful notification delivery'
+      it_behaves_like 'a successful logged action', :suspend, :account
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+
+      it 'marks the target account as suspended' do
+        expect { subject }.to change { target_account.reload.suspended? }.from(false).to(true)
+      end
+    end
+
+    context 'with type of none' do
+      let(:params) { { type: 'none' } }
+
+      it_behaves_like 'a successful notification delivery'
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'with no type' do
+      let(:params) { {} }
+
+      it 'returns http unprocessable entity' do
+        subject
+
+        expect(response).to have_http_status(422)
+      end
+    end
+
+    context 'with invalid type' do
+      let(:params) { { type: 'invalid' } }
+
+      it 'returns http unprocessable entity' do
+        subject
+
+        expect(response).to have_http_status(422)
+      end
+    end
+  end
+end
diff --git a/spec/requests/api/v1/suggestions_spec.rb b/spec/requests/api/v1/suggestions_spec.rb
new file mode 100644
index 0000000000..42b7f86629
--- /dev/null
+++ b/spec/requests/api/v1/suggestions_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Suggestions' do
+  let(:user)    { Fabricate(:user) }
+  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)  { 'read' }
+  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
+
+  describe 'GET /api/v1/suggestions' do
+    subject do
+      get '/api/v1/suggestions', headers: headers, params: params
+    end
+
+    let(:bob)    { Fabricate(:account) }
+    let(:jeff)   { Fabricate(:account) }
+    let(:params) { {} }
+
+    before do
+      PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
+      PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
+    end
+
+    it_behaves_like 'forbidden for wrong scope', 'write'
+
+    it 'returns http success' do
+      subject
+
+      expect(response).to have_http_status(200)
+    end
+
+    it 'returns accounts' do
+      subject
+
+      body = body_as_json
+
+      expect(body.size).to eq 2
+      expect(body.pluck(:id)).to match_array([bob, jeff].map { |i| i.id.to_s })
+    end
+
+    context 'with limit param' do
+      let(:params) { { limit: 1 } }
+
+      it 'returns only the requested number of accounts' do
+        subject
+
+        expect(body_as_json.size).to eq 1
+      end
+    end
+
+    context 'without an authorization header' do
+      let(:headers) { {} }
+
+      it 'returns http unauthorized' do
+        subject
+
+        expect(response).to have_http_status(401)
+      end
+    end
+  end
+
+  describe 'DELETE /api/v1/suggestions/:id' do
+    subject do
+      delete "/api/v1/suggestions/#{jeff.id}", headers: headers
+    end
+
+    let(:suggestions_source) { instance_double(AccountSuggestions::PastInteractionsSource, remove: nil) }
+    let(:bob)                { Fabricate(:account) }
+    let(:jeff)               { Fabricate(:account) }
+
+    before do
+      PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
+      PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
+      allow(AccountSuggestions::PastInteractionsSource).to receive(:new).and_return(suggestions_source)
+    end
+
+    it_behaves_like 'forbidden for wrong scope', 'write'
+
+    it 'returns http success' do
+      subject
+
+      expect(response).to have_http_status(200)
+    end
+
+    it 'removes the specified suggestion' do
+      subject
+
+      expect(suggestions_source).to have_received(:remove).with(user.account, jeff.id.to_s).once
+      expect(suggestions_source).to_not have_received(:remove).with(user.account, bob.id.to_s)
+    end
+
+    context 'without an authorization header' do
+      let(:headers) { {} }
+
+      it 'returns http unauthorized' do
+        subject
+
+        expect(response).to have_http_status(401)
+      end
+    end
+  end
+end
diff --git a/spec/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb
new file mode 100644
index 0000000000..300ddf805c
--- /dev/null
+++ b/spec/requests/api/v1/tags_spec.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Tags' do
+  let(:user)    { Fabricate(:user) }
+  let(:scopes)  { 'write:follows' }
+  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
+
+  describe 'GET /api/v1/tags/:id' do
+    subject do
+      get "/api/v1/tags/#{name}"
+    end
+
+    context 'when the tag exists' do
+      let!(:tag) { Fabricate(:tag) }
+      let(:name) { tag.name }
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+
+      it 'returns the tag' do
+        subject
+
+        expect(body_as_json[:name]).to eq(name)
+      end
+    end
+
+    context 'when the tag does not exist' do
+      let(:name) { 'hoge' }
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'when the tag name is invalid' do
+      let(:name) { 'tag-name' }
+
+      it 'returns http not found' do
+        subject
+
+        expect(response).to have_http_status(404)
+      end
+    end
+  end
+
+  describe 'POST /api/v1/tags/:id/follow' do
+    subject do
+      post "/api/v1/tags/#{name}/follow", headers: headers
+    end
+
+    let!(:tag) { Fabricate(:tag) }
+    let(:name) { tag.name }
+
+    it_behaves_like 'forbidden for wrong scope', 'read read:follows'
+
+    context 'when the tag exists' do
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(:success)
+      end
+
+      it 'creates follow' do
+        subject
+
+        expect(TagFollow.where(tag: tag, account: user.account)).to exist
+      end
+    end
+
+    context 'when the tag does not exist' do
+      let(:name) { 'hoge' }
+
+      it 'returns http success' do
+        subject
+
+        expect(response).to have_http_status(200)
+      end
+
+      it 'creates a new tag with the specified name' do
+        subject
+
+        expect(Tag.where(name: name)).to exist
+      end
+
+      it 'creates follow' do
+        subject
+
+        expect(TagFollow.where(tag: Tag.find_by(name: name), account: user.account)).to exist
+      end
+    end
+
+    context 'when the tag name is invalid' do
+      let(:name) { 'tag-name' }
+
+      it 'returns http not found' do
+        subject
+
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'when the Authorization header is missing' do
+      let(:headers) { {} }
+      let(:name)    { 'unauthorized' }
+
+      it 'returns http unauthorized' do
+        subject
+
+        expect(response).to have_http_status(401)
+      end
+    end
+  end
+
+  describe 'POST #unfollow' do
+    subject do
+      post "/api/v1/tags/#{name}/unfollow", headers: headers
+    end
+
+    let(:name) { tag.name }
+    let!(:tag) { Fabricate(:tag, name: 'foo') }
+
+    before do
+      Fabricate(:tag_follow, account: user.account, tag: tag)
+    end
+
+    it_behaves_like 'forbidden for wrong scope', 'read read:follows'
+
+    it 'returns http success' do
+      subject
+
+      expect(response).to have_http_status(200)
+    end
+
+    it 'removes the follow' do
+      subject
+
+      expect(TagFollow.where(tag: tag, account: user.account)).to_not exist
+    end
+
+    context 'when the tag name is invalid' do
+      let(:name) { 'tag-name' }
+
+      it 'returns http not found' do
+        subject
+
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'when the Authorization header is missing' do
+      let(:headers) { {} }
+      let(:name)    { 'unauthorized' }
+
+      it 'returns http unauthorized' do
+        subject
+
+        expect(response).to have_http_status(401)
+      end
+    end
+  end
+end
diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb
index 98264e6e13..1cd036f484 100644
--- a/spec/services/account_search_service_spec.rb
+++ b/spec/services/account_search_service_spec.rb
@@ -53,7 +53,7 @@ describe AccountSearchService, type: :service do
 
     context 'when there is a domain but no exact match' do
       it 'follows the remote account when resolve is true' do
-        service = double(call: nil)
+        service = instance_double(ResolveAccountService, call: nil)
         allow(ResolveAccountService).to receive(:new).and_return(service)
 
         results = subject.call('newuser@remote.com', nil, limit: 10, resolve: true)
@@ -61,7 +61,7 @@ describe AccountSearchService, type: :service do
       end
 
       it 'does not follow the remote account when resolve is false' do
-        service = double(call: nil)
+        service = instance_double(ResolveAccountService, call: nil)
         allow(ResolveAccountService).to receive(:new).and_return(service)
 
         results = subject.call('newuser@remote.com', nil, limit: 10, resolve: false)
diff --git a/spec/services/backup_service_spec.rb b/spec/services/backup_service_spec.rb
index 73e0b42adb..806ba18323 100644
--- a/spec/services/backup_service_spec.rb
+++ b/spec/services/backup_service_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe BackupService, type: :service do
     it 'stores them as expected' do
       service_call
 
-      json = Oj.load(read_zip_file(backup, 'actor.json'))
+      json = export_json(:actor)
       avatar_path = json.dig('icon', 'url')
       header_path = json.dig('image', 'url')
 
@@ -42,47 +42,60 @@ RSpec.describe BackupService, type: :service do
     end
   end
 
-  it 'marks the backup as processed' do
-    expect { service_call }.to change(backup, :processed).from(false).to(true)
+  it 'marks the backup as processed and exports files' do
+    expect { service_call }.to process_backup
+
+    expect_outbox_export
+    expect_likes_export
+    expect_bookmarks_export
   end
 
-  it 'exports outbox.json as expected' do
-    service_call
+  def process_backup
+    change(backup, :processed).from(false).to(true)
+  end
 
-    json = Oj.load(read_zip_file(backup, 'outbox.json'))
-    expect(json['@context']).to_not be_nil
-    expect(json['type']).to eq 'OrderedCollection'
-    expect(json['totalItems']).to eq 2
-    expect(json['orderedItems'][0]['@context']).to be_nil
-    expect(json['orderedItems'][0]).to include({
+  def expect_outbox_export
+    json = export_json(:outbox)
+
+    aggregate_failures do
+      expect(json['@context']).to_not be_nil
+      expect(json['type']).to eq 'OrderedCollection'
+      expect(json['totalItems']).to eq 2
+      expect(json['orderedItems'][0]['@context']).to be_nil
+      expect(json['orderedItems'][0]).to include_create_item(status)
+      expect(json['orderedItems'][1]).to include_create_item(private_status)
+    end
+  end
+
+  def expect_likes_export
+    json = export_json(:likes)
+
+    aggregate_failures do
+      expect(json['type']).to eq 'OrderedCollection'
+      expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)]
+    end
+  end
+
+  def expect_bookmarks_export
+    json = export_json(:bookmarks)
+
+    aggregate_failures do
+      expect(json['type']).to eq 'OrderedCollection'
+      expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
+    end
+  end
+
+  def export_json(type)
+    Oj.load(read_zip_file(backup, "#{type}.json"))
+  end
+
+  def include_create_item(status)
+    include({
       'type' => 'Create',
       'object' => include({
         'id' => ActivityPub::TagManager.instance.uri_for(status),
-        'content' => '<p>Hello</p>',
+        'content' => "<p>#{status.text}</p>",
       }),
     })
-    expect(json['orderedItems'][1]).to include({
-      'type' => 'Create',
-      'object' => include({
-        'id' => ActivityPub::TagManager.instance.uri_for(private_status),
-        'content' => '<p>secret</p>',
-      }),
-    })
-  end
-
-  it 'exports likes.json as expected' do
-    service_call
-
-    json = Oj.load(read_zip_file(backup, 'likes.json'))
-    expect(json['type']).to eq 'OrderedCollection'
-    expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)]
-  end
-
-  it 'exports bookmarks.json as expected' do
-    service_call
-
-    json = Oj.load(read_zip_file(backup, 'bookmarks.json'))
-    expect(json['type']).to eq 'OrderedCollection'
-    expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
   end
 end
diff --git a/spec/services/bootstrap_timeline_service_spec.rb b/spec/services/bootstrap_timeline_service_spec.rb
index 5a15ba7418..721a0337fd 100644
--- a/spec/services/bootstrap_timeline_service_spec.rb
+++ b/spec/services/bootstrap_timeline_service_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe BootstrapTimelineService, type: :service do
   subject { described_class.new }
 
   context 'when the new user has registered from an invite' do
-    let(:service)    { double }
+    let(:service)    { instance_double(FollowService) }
     let(:autofollow) { false }
     let(:inviter)    { Fabricate(:user, confirmed_at: 2.days.ago) }
     let(:invite)     { Fabricate(:invite, user: inviter, max_uses: nil, expires_at: 1.hour.from_now, autofollow: autofollow) }
diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb
index 09dfb0a0b6..281b642ea4 100644
--- a/spec/services/bulk_import_service_spec.rb
+++ b/spec/services/bulk_import_service_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe BulkImportService do
       it 'requests to follow all the listed users once the workers have run' do
         subject.call(import)
 
-        resolve_account_service_double = double
+        resolve_account_service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
         allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
         allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@@ -95,7 +95,7 @@ RSpec.describe BulkImportService do
       it 'requests to follow all the expected users once the workers have run' do
         subject.call(import)
 
-        resolve_account_service_double = double
+        resolve_account_service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
         allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
         allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@@ -133,7 +133,7 @@ RSpec.describe BulkImportService do
       it 'blocks all the listed users once the workers have run' do
         subject.call(import)
 
-        resolve_account_service_double = double
+        resolve_account_service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
         allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
         allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@@ -177,7 +177,7 @@ RSpec.describe BulkImportService do
       it 'requests to follow all the expected users once the workers have run' do
         subject.call(import)
 
-        resolve_account_service_double = double
+        resolve_account_service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
         allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
         allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@@ -215,7 +215,7 @@ RSpec.describe BulkImportService do
       it 'mutes all the listed users once the workers have run' do
         subject.call(import)
 
-        resolve_account_service_double = double
+        resolve_account_service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
         allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
         allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@@ -263,7 +263,7 @@ RSpec.describe BulkImportService do
       it 'requests to follow all the expected users once the workers have run' do
         subject.call(import)
 
-        resolve_account_service_double = double
+        resolve_account_service_double = instance_double(ResolveAccountService)
         allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
         allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
         allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@@ -360,7 +360,7 @@ RSpec.describe BulkImportService do
       it 'updates the bookmarks as expected once the workers have run' do
         subject.call(import)
 
-        service_double = double
+        service_double = instance_double(ActivityPub::FetchRemoteStatusService)
         allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double)
         allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') }
         allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) }
@@ -403,7 +403,7 @@ RSpec.describe BulkImportService do
       it 'updates the bookmarks as expected once the workers have run' do
         subject.call(import)
 
-        service_double = double
+        service_double = instance_double(ActivityPub::FetchRemoteStatusService)
         allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double)
         allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') }
         allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) }
diff --git a/spec/services/fetch_resource_service_spec.rb b/spec/services/fetch_resource_service_spec.rb
index da7e423517..0f1068471f 100644
--- a/spec/services/fetch_resource_service_spec.rb
+++ b/spec/services/fetch_resource_service_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe FetchResourceService, type: :service do
 
     context 'when OpenSSL::SSL::SSLError is raised' do
       before do
-        request = double
+        request = instance_double(Request)
         allow(Request).to receive(:new).and_return(request)
         allow(request).to receive(:add_headers)
         allow(request).to receive(:on_behalf_of)
@@ -36,7 +36,7 @@ RSpec.describe FetchResourceService, type: :service do
 
     context 'when HTTP::ConnectionError is raised' do
       before do
-        request = double
+        request = instance_double(Request)
         allow(Request).to receive(:new).and_return(request)
         allow(request).to receive(:add_headers)
         allow(request).to receive(:on_behalf_of)
diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb
index 32ba4409c3..1904ac8dc9 100644
--- a/spec/services/import_service_spec.rb
+++ b/spec/services/import_service_spec.rb
@@ -219,7 +219,7 @@ RSpec.describe ImportService, type: :service do
     end
 
     before do
-      service = double
+      service = instance_double(ActivityPub::FetchRemoteStatusService)
       allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service)
       allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do
         Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1')
diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb
index 76ef5391f0..d201292e17 100644
--- a/spec/services/post_status_service_spec.rb
+++ b/spec/services/post_status_service_spec.rb
@@ -132,7 +132,7 @@ RSpec.describe PostStatusService, type: :service do
   end
 
   it 'processes mentions' do
-    mention_service = double(:process_mentions_service)
+    mention_service = instance_double(ProcessMentionsService)
     allow(mention_service).to receive(:call)
     allow(ProcessMentionsService).to receive(:new).and_return(mention_service)
     account = Fabricate(:account)
@@ -163,7 +163,7 @@ RSpec.describe PostStatusService, type: :service do
   end
 
   it 'processes hashtags' do
-    hashtags_service = double(:process_hashtags_service)
+    hashtags_service = instance_double(ProcessHashtagsService)
     allow(hashtags_service).to receive(:call)
     allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service)
     account = Fabricate(:account)
diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb
index 8d2af74173..ad5bebb4ed 100644
--- a/spec/services/resolve_url_service_spec.rb
+++ b/spec/services/resolve_url_service_spec.rb
@@ -9,7 +9,7 @@ describe ResolveURLService, type: :service do
     it 'returns nil when there is no resource url' do
       url           = 'http://example.com/missing-resource'
       known_account = Fabricate(:account, uri: url)
-      service = double
+      service = instance_double(FetchResourceService)
 
       allow(FetchResourceService).to receive(:new).and_return service
       allow(service).to receive(:response_code).and_return(404)
@@ -21,7 +21,7 @@ describe ResolveURLService, type: :service do
     it 'returns known account on temporary error' do
       url           = 'http://example.com/missing-resource'
       known_account = Fabricate(:account, uri: url)
-      service = double
+      service = instance_double(FetchResourceService)
 
       allow(FetchResourceService).to receive(:new).and_return service
       allow(service).to receive(:response_code).and_return(500)
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 00f693dfab..1283a23bf1 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -25,7 +25,7 @@ describe SearchService, type: :service do
 
       context 'when it does not find anything' do
         it 'returns the empty results' do
-          service = double(call: nil)
+          service = instance_double(ResolveURLService, call: nil)
           allow(ResolveURLService).to receive(:new).and_return(service)
           results = subject.call(@query, nil, 10, resolve: true)
 
@@ -37,7 +37,7 @@ describe SearchService, type: :service do
       context 'when it finds an account' do
         it 'includes the account in the results' do
           account = Account.new
-          service = double(call: account)
+          service = instance_double(ResolveURLService, call: account)
           allow(ResolveURLService).to receive(:new).and_return(service)
 
           results = subject.call(@query, nil, 10, resolve: true)
@@ -49,7 +49,7 @@ describe SearchService, type: :service do
       context 'when it finds a status' do
         it 'includes the status in the results' do
           status = Status.new
-          service = double(call: status)
+          service = instance_double(ResolveURLService, call: status)
           allow(ResolveURLService).to receive(:new).and_return(service)
 
           results = subject.call(@query, nil, 10, resolve: true)
@@ -64,7 +64,7 @@ describe SearchService, type: :service do
         it 'includes the account in the results' do
           query = 'username'
           account = Account.new
-          service = double(call: [account])
+          service = instance_double(AccountSearchService, call: [account])
           allow(AccountSearchService).to receive(:new).and_return(service)
 
           results = subject.call(query, nil, 10)
diff --git a/spec/services/unsuspend_account_service_spec.rb b/spec/services/unsuspend_account_service_spec.rb
index e02ae41b99..7ef2630aeb 100644
--- a/spec/services/unsuspend_account_service_spec.rb
+++ b/spec/services/unsuspend_account_service_spec.rb
@@ -63,7 +63,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
   describe 'unsuspending a remote account' do
     include_examples 'with common context' do
       let!(:account)                 { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
-      let!(:resolve_account_service) { double }
+      let!(:resolve_account_service) { instance_double(ResolveAccountService) }
 
       before do
         allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service)
diff --git a/spec/validators/blacklisted_email_validator_spec.rb b/spec/validators/blacklisted_email_validator_spec.rb
index a642405ae6..3d3d50f659 100644
--- a/spec/validators/blacklisted_email_validator_spec.rb
+++ b/spec/validators/blacklisted_email_validator_spec.rb
@@ -6,8 +6,8 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
   describe '#validate' do
     subject { described_class.new.validate(user); errors }
 
-    let(:user)   { double(email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) }
-    let(:errors) { double(add: nil) }
+    let(:user)   { instance_double(User, email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) }
+    let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
 
     before do
       allow(user).to receive(:valid_invitation?).and_return(false)
diff --git a/spec/validators/disallowed_hashtags_validator_spec.rb b/spec/validators/disallowed_hashtags_validator_spec.rb
index e98db38792..7144d28918 100644
--- a/spec/validators/disallowed_hashtags_validator_spec.rb
+++ b/spec/validators/disallowed_hashtags_validator_spec.rb
@@ -11,8 +11,8 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do
       described_class.new.validate(status)
     end
 
-    let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) }
-    let(:errors) { double(add: nil) }
+    let(:status) { instance_double(Status, errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) }
+    let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
 
     context 'with a remote reblog' do
       let(:local)  { false }
diff --git a/spec/validators/email_mx_validator_spec.rb b/spec/validators/email_mx_validator_spec.rb
index d9703d81b1..876d73c184 100644
--- a/spec/validators/email_mx_validator_spec.rb
+++ b/spec/validators/email_mx_validator_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'
 
 describe EmailMxValidator do
   describe '#validate' do
-    let(:user) { double(email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) }
+    let(:user) { instance_double(User, email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil)) }
 
     context 'with an e-mail domain that is explicitly allowed' do
       around do |block|
@@ -15,7 +15,7 @@ describe EmailMxValidator do
       end
 
       it 'does not add errors if there are no DNS records' do
-        resolver = double
+        resolver = instance_double(Resolv::DNS)
 
         allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
         allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
@@ -29,7 +29,7 @@ describe EmailMxValidator do
     end
 
     it 'adds no error if there are DNS records for the e-mail domain' do
-      resolver = double
+      resolver = instance_double(Resolv::DNS)
 
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')])
@@ -46,19 +46,19 @@ describe EmailMxValidator do
       allow(TagManager).to receive(:instance).and_return(double)
       allow(double).to receive(:normalize_domain).with('example.com').and_raise(Addressable::URI::InvalidURIError)
 
-      user = double(email: 'foo@example.com', errors: double(add: nil))
+      user = instance_double(User, email: 'foo@example.com', errors: instance_double(ActiveModel::Errors, add: nil))
       subject.validate(user)
       expect(user.errors).to have_received(:add)
     end
 
     it 'adds an error if the domain email portion is blank' do
-      user = double(email: 'foo@', errors: double(add: nil))
+      user = instance_double(User, email: 'foo@', errors: instance_double(ActiveModel::Errors, add: nil))
       subject.validate(user)
       expect(user.errors).to have_received(:add)
     end
 
     it 'adds an error if the email domain name contains empty labels' do
-      resolver = double
+      resolver = instance_double(Resolv::DNS)
 
       allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::MX).and_return([])
       allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')])
@@ -66,13 +66,13 @@ describe EmailMxValidator do
       allow(resolver).to receive(:timeouts=).and_return(nil)
       allow(Resolv::DNS).to receive(:open).and_yield(resolver)
 
-      user = double(email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: double(add: nil))
+      user = instance_double(User, email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil))
       subject.validate(user)
       expect(user.errors).to have_received(:add)
     end
 
     it 'adds an error if there are no DNS records for the e-mail domain' do
-      resolver = double
+      resolver = instance_double(Resolv::DNS)
 
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
@@ -85,9 +85,11 @@ describe EmailMxValidator do
     end
 
     it 'adds an error if a MX record does not lead to an IP' do
-      resolver = double
+      resolver = instance_double(Resolv::DNS)
 
-      allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
+      allow(resolver).to receive(:getresources)
+        .with('example.com', Resolv::DNS::Resource::IN::MX)
+        .and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')])
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
       allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([])
@@ -101,13 +103,15 @@ describe EmailMxValidator do
 
     it 'adds an error if the MX record is blacklisted' do
       EmailDomainBlock.create!(domain: 'mail.example.com')
-      resolver = double
+      resolver = instance_double(Resolv::DNS)
 
-      allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
+      allow(resolver).to receive(:getresources)
+        .with('example.com', Resolv::DNS::Resource::IN::MX)
+        .and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')])
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
       allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
-      allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')])
-      allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')])
+      allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: '2.3.4.5')])
+      allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: 'fd00::2')])
       allow(resolver).to receive(:timeouts=).and_return(nil)
       allow(Resolv::DNS).to receive(:open).and_yield(resolver)
 
diff --git a/spec/validators/follow_limit_validator_spec.rb b/spec/validators/follow_limit_validator_spec.rb
index 7b9055a27f..86b6511d65 100644
--- a/spec/validators/follow_limit_validator_spec.rb
+++ b/spec/validators/follow_limit_validator_spec.rb
@@ -12,9 +12,9 @@ RSpec.describe FollowLimitValidator, type: :validator do
       described_class.new.validate(follow)
     end
 
-    let(:follow)  { double(account: account, errors: errors) }
-    let(:errors)  { double(add: nil) }
-    let(:account) { double(nil?: _nil, local?: local, following_count: 0, followers_count: 0) }
+    let(:follow)  { instance_double(Follow, account: account, errors: errors) }
+    let(:errors)  { instance_double(ActiveModel::Errors, add: nil) }
+    let(:account) { instance_double(Account, nil?: _nil, local?: local, following_count: 0, followers_count: 0) }
     let(:_nil)    { true }
     let(:local)   { false }
 
diff --git a/spec/validators/note_length_validator_spec.rb b/spec/validators/note_length_validator_spec.rb
index e45d221d76..66fccad3ec 100644
--- a/spec/validators/note_length_validator_spec.rb
+++ b/spec/validators/note_length_validator_spec.rb
@@ -8,7 +8,7 @@ describe NoteLengthValidator do
   describe '#validate' do
     it 'adds an error when text is over 500 characters' do
       text = 'a' * 520
-      account = double(note: text, errors: double(add: nil))
+      account = instance_double(Account, note: text, errors: activemodel_errors)
 
       subject.validate_each(account, 'note', text)
       expect(account.errors).to have_received(:add)
@@ -16,7 +16,7 @@ describe NoteLengthValidator do
 
     it 'counts URLs as 23 characters flat' do
       text = ('a' * 476) + " http://#{'b' * 30}.com/example"
-      account = double(note: text, errors: double(add: nil))
+      account = instance_double(Account, note: text, errors: activemodel_errors)
 
       subject.validate_each(account, 'note', text)
       expect(account.errors).to_not have_received(:add)
@@ -24,10 +24,16 @@ describe NoteLengthValidator do
 
     it 'does not count non-autolinkable URLs as 23 characters flat' do
       text = ('a' * 476) + "http://#{'b' * 30}.com/example"
-      account = double(note: text, errors: double(add: nil))
+      account = instance_double(Account, note: text, errors: activemodel_errors)
 
       subject.validate_each(account, 'note', text)
       expect(account.errors).to have_received(:add)
     end
+
+    private
+
+    def activemodel_errors
+      instance_double(ActiveModel::Errors, add: nil)
+    end
   end
 end
diff --git a/spec/validators/poll_validator_spec.rb b/spec/validators/poll_validator_spec.rb
index 069a471619..95feb043db 100644
--- a/spec/validators/poll_validator_spec.rb
+++ b/spec/validators/poll_validator_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe PollValidator, type: :validator do
     end
 
     let(:validator) { described_class.new }
-    let(:poll) { double(options: options, expires_at: expires_at, errors: errors) }
-    let(:errors) { double(add: nil) }
+    let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) }
+    let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
     let(:options) { %w(foo bar) }
     let(:expires_at) { 1.day.from_now }
 
diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb
index 7e06b9bd94..8535ddd750 100644
--- a/spec/validators/status_length_validator_spec.rb
+++ b/spec/validators/status_length_validator_spec.rb
@@ -5,27 +5,27 @@ require 'rails_helper'
 describe StatusLengthValidator do
   describe '#validate' do
     it 'does not add errors onto remote statuses' do
-      status = double(local?: false)
+      status = instance_double(Status, local?: false)
       subject.validate(status)
       expect(status).to_not receive(:errors)
     end
 
     it 'does not add errors onto local reblogs' do
-      status = double(local?: false, reblog?: true)
+      status = instance_double(Status, local?: false, reblog?: true)
       subject.validate(status)
       expect(status).to_not receive(:errors)
     end
 
     it 'adds an error when content warning is over MAX_CHARS characters' do
       chars = StatusLengthValidator::MAX_CHARS + 1
-      status = double(spoiler_text: 'a' * chars, text: '', errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: 'a' * chars, text: '', errors: activemodel_errors, local?: true, reblog?: false)
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
 
     it 'adds an error when text is over MAX_CHARS characters' do
       chars = StatusLengthValidator::MAX_CHARS + 1
-      status = double(spoiler_text: '', text: 'a' * chars, errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: '', text: 'a' * chars, errors: activemodel_errors, local?: true, reblog?: false)
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
@@ -33,7 +33,7 @@ describe StatusLengthValidator do
     it 'adds an error when text and content warning are over MAX_CHARS characters total' do
       chars1 = 20
       chars2 = StatusLengthValidator::MAX_CHARS + 1 - chars1
-      status = double(spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: activemodel_errors, local?: true, reblog?: false)
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
@@ -41,7 +41,7 @@ describe StatusLengthValidator do
     it 'counts URLs as 23 characters flat' do
       chars = StatusLengthValidator::MAX_CHARS - 1 - 23
       text   = ('a' * chars) + " http://#{'b' * 30}.com/example"
-      status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
 
       subject.validate(status)
       expect(status.errors).to_not have_received(:add)
@@ -49,7 +49,7 @@ describe StatusLengthValidator do
 
     it 'does not count non-autolinkable URLs as 23 characters flat' do
       text   = ('a' * 476) + "http://#{'b' * 30}.com/example"
-      status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
 
       subject.validate(status)
       expect(status.errors).to have_received(:add)
@@ -57,7 +57,7 @@ describe StatusLengthValidator do
 
     it 'does not count overly long URLs as 23 characters flat' do
       text = "http://example.com/valid?#{'#foo?' * 1000}"
-      status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
@@ -66,7 +66,7 @@ describe StatusLengthValidator do
       username = '@alice'
       chars = StatusLengthValidator::MAX_CHARS - 1 - username.length
       text   = ('a' * chars) + " #{username}@#{'b' * 30}.com"
-      status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
 
       subject.validate(status)
       expect(status.errors).to_not have_received(:add)
@@ -74,10 +74,16 @@ describe StatusLengthValidator do
 
     it 'does count both parts of remote usernames for overly long domains' do
       text   = "@alice@#{'b' * 500}.com"
-      status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+      status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
 
       subject.validate(status)
       expect(status.errors).to have_received(:add)
     end
   end
+
+  private
+
+  def activemodel_errors
+    instance_double(ActiveModel::Errors, add: nil)
+  end
 end
diff --git a/spec/validators/status_pin_validator_spec.rb b/spec/validators/status_pin_validator_spec.rb
index 00b89d702f..e8f8a45434 100644
--- a/spec/validators/status_pin_validator_spec.rb
+++ b/spec/validators/status_pin_validator_spec.rb
@@ -8,11 +8,11 @@ RSpec.describe StatusPinValidator, type: :validator do
       subject.validate(pin)
     end
 
-    let(:pin) { double(account: account, errors: errors, status: status, account_id: pin_account_id) }
-    let(:status) { double(reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') }
-    let(:account)     { double(status_pins: status_pins, local?: local) }
-    let(:status_pins) { double(count: count) }
-    let(:errors)      { double(add: nil) }
+    let(:pin) { instance_double(StatusPin, account: account, errors: errors, status: status, account_id: pin_account_id) }
+    let(:status) { instance_double(Status, reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') }
+    let(:account)     { instance_double(Account, status_pins: status_pins, local?: local) }
+    let(:status_pins) { instance_double(Array, count: count) }
+    let(:errors)      { instance_double(ActiveModel::Errors, add: nil) }
     let(:pin_account_id)    { 1 }
     let(:status_account_id) { 1 }
     let(:visibility)  { 'public' }
diff --git a/spec/validators/unique_username_validator_spec.rb b/spec/validators/unique_username_validator_spec.rb
index 6867cbc6ce..0d172c8408 100644
--- a/spec/validators/unique_username_validator_spec.rb
+++ b/spec/validators/unique_username_validator_spec.rb
@@ -6,7 +6,7 @@ describe UniqueUsernameValidator do
   describe '#validate' do
     context 'when local account' do
       it 'does not add errors if username is nil' do
-        account = double(username: nil, domain: nil, persisted?: false, errors: double(add: nil))
+        account = instance_double(Account, username: nil, domain: nil, persisted?: false, errors: activemodel_errors)
         subject.validate(account)
         expect(account.errors).to_not have_received(:add)
       end
@@ -18,14 +18,14 @@ describe UniqueUsernameValidator do
 
       it 'adds an error when the username is already used with ignoring cases' do
         Fabricate(:account, username: 'ABCdef')
-        account = double(username: 'abcDEF', domain: nil, persisted?: false, errors: double(add: nil))
+        account = instance_double(Account, username: 'abcDEF', domain: nil, persisted?: false, errors: activemodel_errors)
         subject.validate(account)
         expect(account.errors).to have_received(:add)
       end
 
       it 'does not add errors when same username remote account exists' do
         Fabricate(:account, username: 'abcdef', domain: 'example.com')
-        account = double(username: 'abcdef', domain: nil, persisted?: false, errors: double(add: nil))
+        account = instance_double(Account, username: 'abcdef', domain: nil, persisted?: false, errors: activemodel_errors)
         subject.validate(account)
         expect(account.errors).to_not have_received(:add)
       end
@@ -34,7 +34,7 @@ describe UniqueUsernameValidator do
 
   context 'when remote account' do
     it 'does not add errors if username is nil' do
-      account = double(username: nil, domain: 'example.com', persisted?: false, errors: double(add: nil))
+      account = instance_double(Account, username: nil, domain: 'example.com', persisted?: false, errors: activemodel_errors)
       subject.validate(account)
       expect(account.errors).to_not have_received(:add)
     end
@@ -46,23 +46,29 @@ describe UniqueUsernameValidator do
 
     it 'adds an error when the username is already used with ignoring cases' do
       Fabricate(:account, username: 'ABCdef', domain: 'example.com')
-      account = double(username: 'abcDEF', domain: 'example.com', persisted?: false, errors: double(add: nil))
+      account = instance_double(Account, username: 'abcDEF', domain: 'example.com', persisted?: false, errors: activemodel_errors)
       subject.validate(account)
       expect(account.errors).to have_received(:add)
     end
 
     it 'adds an error when the domain is already used with ignoring cases' do
       Fabricate(:account, username: 'ABCdef', domain: 'example.com')
-      account = double(username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: double(add: nil))
+      account = instance_double(Account, username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: activemodel_errors)
       subject.validate(account)
       expect(account.errors).to have_received(:add)
     end
 
     it 'does not add errors when account with the same username and another domain exists' do
       Fabricate(:account, username: 'abcdef', domain: 'example.com')
-      account = double(username: 'abcdef', domain: 'example2.com', persisted?: false, errors: double(add: nil))
+      account = instance_double(Account, username: 'abcdef', domain: 'example2.com', persisted?: false, errors: activemodel_errors)
       subject.validate(account)
       expect(account.errors).to_not have_received(:add)
     end
   end
+
+  private
+
+  def activemodel_errors
+    instance_double(ActiveModel::Errors, add: nil)
+  end
 end
diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb
index 85bd7dcb6a..6f353eeafd 100644
--- a/spec/validators/unreserved_username_validator_spec.rb
+++ b/spec/validators/unreserved_username_validator_spec.rb
@@ -10,8 +10,8 @@ RSpec.describe UnreservedUsernameValidator, type: :validator do
     end
 
     let(:validator) { described_class.new }
-    let(:account)   { double(username: username, errors: errors) }
-    let(:errors) { double(add: nil) }
+    let(:account)   { instance_double(Account, username: username, errors: errors) }
+    let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
 
     context 'when @username is blank?' do
       let(:username) { nil }
diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb
index a56ccd8e08..f2220e32b0 100644
--- a/spec/validators/url_validator_spec.rb
+++ b/spec/validators/url_validator_spec.rb
@@ -10,8 +10,8 @@ RSpec.describe URLValidator, type: :validator do
     end
 
     let(:validator) { described_class.new(attributes: [attribute]) }
-    let(:record)    { double(errors: errors) }
-    let(:errors)    { double(add: nil) }
+    let(:record)    { instance_double(Webhook, errors: errors) }
+    let(:errors)    { instance_double(ActiveModel::Errors, add: nil) }
     let(:value)     { '' }
     let(:attribute) { :foo }
 
diff --git a/spec/views/statuses/show.html.haml_spec.rb b/spec/views/statuses/show.html.haml_spec.rb
index 370743dfec..06f5132d9f 100644
--- a/spec/views/statuses/show.html.haml_spec.rb
+++ b/spec/views/statuses/show.html.haml_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'
 
 describe 'statuses/show.html.haml', without_verify_partial_doubles: true do
   before do
-    double(api_oembed_url: '')
+    allow(view).to receive(:api_oembed_url).and_return('')
     allow(view).to receive(:show_landing_strip?).and_return(true)
     allow(view).to receive(:site_title).and_return('example site')
     allow(view).to receive(:site_hostname).and_return('example.com')
diff --git a/spec/workers/activitypub/processing_worker_spec.rb b/spec/workers/activitypub/processing_worker_spec.rb
index 6b57f16a92..66d1cf4890 100644
--- a/spec/workers/activitypub/processing_worker_spec.rb
+++ b/spec/workers/activitypub/processing_worker_spec.rb
@@ -9,7 +9,8 @@ describe ActivityPub::ProcessingWorker do
 
   describe '#perform' do
     it 'delegates to ActivityPub::ProcessCollectionService' do
-      allow(ActivityPub::ProcessCollectionService).to receive(:new).and_return(double(:service, call: nil))
+      allow(ActivityPub::ProcessCollectionService).to receive(:new)
+        .and_return(instance_double(ActivityPub::ProcessCollectionService, call: nil))
       subject.perform(account.id, '')
       expect(ActivityPub::ProcessCollectionService).to have_received(:new)
     end
diff --git a/spec/workers/admin/domain_purge_worker_spec.rb b/spec/workers/admin/domain_purge_worker_spec.rb
index b67c58b234..861fd71a7f 100644
--- a/spec/workers/admin/domain_purge_worker_spec.rb
+++ b/spec/workers/admin/domain_purge_worker_spec.rb
@@ -7,7 +7,7 @@ describe Admin::DomainPurgeWorker do
 
   describe 'perform' do
     it 'calls domain purge service for relevant domain block' do
-      service = double(call: nil)
+      service = instance_double(PurgeDomainService, call: nil)
       allow(PurgeDomainService).to receive(:new).and_return(service)
       result = subject.perform('example.com')
 
diff --git a/spec/workers/domain_block_worker_spec.rb b/spec/workers/domain_block_worker_spec.rb
index 8b98443fa7..33c3ca009a 100644
--- a/spec/workers/domain_block_worker_spec.rb
+++ b/spec/workers/domain_block_worker_spec.rb
@@ -9,7 +9,7 @@ describe DomainBlockWorker do
     let(:domain_block) { Fabricate(:domain_block) }
 
     it 'calls domain block service for relevant domain block' do
-      service = double(call: nil)
+      service = instance_double(BlockDomainService, call: nil)
       allow(BlockDomainService).to receive(:new).and_return(service)
       result = subject.perform(domain_block.id)
 
diff --git a/spec/workers/domain_clear_media_worker_spec.rb b/spec/workers/domain_clear_media_worker_spec.rb
index f21d1fe189..21f8f87b2f 100644
--- a/spec/workers/domain_clear_media_worker_spec.rb
+++ b/spec/workers/domain_clear_media_worker_spec.rb
@@ -9,7 +9,7 @@ describe DomainClearMediaWorker do
     let(:domain_block) { Fabricate(:domain_block, severity: :silence, reject_media: true) }
 
     it 'calls domain clear media service for relevant domain block' do
-      service = double(call: nil)
+      service = instance_double(ClearDomainMediaService, call: nil)
       allow(ClearDomainMediaService).to receive(:new).and_return(service)
       result = subject.perform(domain_block.id)
 
diff --git a/spec/workers/feed_insert_worker_spec.rb b/spec/workers/feed_insert_worker_spec.rb
index 16f7d73e02..97c73c5999 100644
--- a/spec/workers/feed_insert_worker_spec.rb
+++ b/spec/workers/feed_insert_worker_spec.rb
@@ -11,7 +11,7 @@ describe FeedInsertWorker do
 
     context 'when there are no records' do
       it 'skips push with missing status' do
-        instance = double(push_to_home: nil)
+        instance = instance_double(FeedManager, push_to_home: nil)
         allow(FeedManager).to receive(:instance).and_return(instance)
         result = subject.perform(nil, follower.id)
 
@@ -20,7 +20,7 @@ describe FeedInsertWorker do
       end
 
       it 'skips push with missing account' do
-        instance = double(push_to_home: nil)
+        instance = instance_double(FeedManager, push_to_home: nil)
         allow(FeedManager).to receive(:instance).and_return(instance)
         result = subject.perform(status.id, nil)
 
@@ -31,7 +31,7 @@ describe FeedInsertWorker do
 
     context 'when there are real records' do
       it 'skips the push when there is a filter' do
-        instance = double(push_to_home: nil, filter?: true)
+        instance = instance_double(FeedManager, push_to_home: nil, filter?: true)
         allow(FeedManager).to receive(:instance).and_return(instance)
         result = subject.perform(status.id, follower.id)
 
@@ -40,7 +40,7 @@ describe FeedInsertWorker do
       end
 
       it 'pushes the status onto the home timeline without filter' do
-        instance = double(push_to_home: nil, filter?: false)
+        instance = instance_double(FeedManager, push_to_home: nil, filter?: false)
         allow(FeedManager).to receive(:instance).and_return(instance)
         result = subject.perform(status.id, follower.id)
 
diff --git a/spec/workers/move_worker_spec.rb b/spec/workers/move_worker_spec.rb
index ac7bd506b6..7577f6e896 100644
--- a/spec/workers/move_worker_spec.rb
+++ b/spec/workers/move_worker_spec.rb
@@ -15,7 +15,7 @@ describe MoveWorker do
   let!(:account_note)    { Fabricate(:account_note, account: local_user.account, target_account: source_account, comment: comment) }
   let(:list)             { Fabricate(:list, account: local_follower) }
 
-  let(:block_service) { double }
+  let(:block_service) { instance_double(BlockService) }
 
   before do
     stub_request(:post, 'https://example.org/a/inbox').to_return(status: 200)
diff --git a/spec/workers/publish_scheduled_announcement_worker_spec.rb b/spec/workers/publish_scheduled_announcement_worker_spec.rb
index 0977bba1ee..2e50d4a50d 100644
--- a/spec/workers/publish_scheduled_announcement_worker_spec.rb
+++ b/spec/workers/publish_scheduled_announcement_worker_spec.rb
@@ -12,7 +12,7 @@ describe PublishScheduledAnnouncementWorker do
 
   describe 'perform' do
     before do
-      service = double
+      service = instance_double(FetchRemoteStatusService)
       allow(FetchRemoteStatusService).to receive(:new).and_return(service)
       allow(service).to receive(:call).with('https://domain.com/users/foo/12345') { remote_status.reload }
 
diff --git a/spec/workers/refollow_worker_spec.rb b/spec/workers/refollow_worker_spec.rb
index 1dac15385b..5718d4db49 100644
--- a/spec/workers/refollow_worker_spec.rb
+++ b/spec/workers/refollow_worker_spec.rb
@@ -10,7 +10,7 @@ describe RefollowWorker do
   let(:bob)     { Fabricate(:account, domain: nil, username: 'bob') }
 
   describe 'perform' do
-    let(:service) { double }
+    let(:service) { instance_double(FollowService) }
 
     before do
       allow(FollowService).to receive(:new).and_return(service)
diff --git a/spec/workers/regeneration_worker_spec.rb b/spec/workers/regeneration_worker_spec.rb
index 147a76be50..37b0a04c49 100644
--- a/spec/workers/regeneration_worker_spec.rb
+++ b/spec/workers/regeneration_worker_spec.rb
@@ -9,7 +9,7 @@ describe RegenerationWorker do
     let(:account) { Fabricate(:account) }
 
     it 'calls the precompute feed service for the account' do
-      service = double(call: nil)
+      service = instance_double(PrecomputeFeedService, call: nil)
       allow(PrecomputeFeedService).to receive(:new).and_return(service)
       result = subject.perform(account.id)
 
diff --git a/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
index 5565636d57..4d9185093a 100644
--- a/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
+++ b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
@@ -75,6 +75,12 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
   end
 
   describe '#perform' do
+    around do |example|
+      Timeout.timeout(30) do
+        example.run
+      end
+    end
+
     before do
       # Policies for the accounts
       Fabricate(:account_statuses_cleanup_policy, account: account_alice)
diff --git a/yarn.lock b/yarn.lock
index f2d02f0c86..cb8dbfc66a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4724,9 +4724,9 @@ domutils@^3.0.1:
     domhandler "^5.0.3"
 
 dotenv@^16.0.3:
-  version "16.1.4"
-  resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.1.4.tgz#67ac1a10cd9c25f5ba604e4e08bc77c0ebe0ca8c"
-  integrity sha512-m55RtE8AsPeJBpOIFKihEmqUcoVncQIwo7x9U8ZwLEZw9ZpXboz2c+rvog+jUaJvVrZ5kBOeYQBX5+8Aa/OZQw==
+  version "16.3.1"
+  resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e"
+  integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==
 
 duplexer@^0.1.2:
   version "0.1.2"
@@ -10747,6 +10747,7 @@ string-length@^4.0.1:
     strip-ansi "^6.0.0"
 
 "string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+  name string-width-cjs
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -10845,6 +10846,7 @@ stringz@^2.1.0:
     char-regex "^1.0.2"
 
 "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  name strip-ansi-cjs
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -12246,6 +12248,7 @@ workbox-window@7.0.0, workbox-window@^7.0.0:
     workbox-core "7.0.0"
 
 "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+  name wrap-ansi-cjs
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==