~cytrogen/masto-fe

178e151019cc1b0d5a537543e7d2f6b4712b9fd4 — Claire 2 years ago eba3411 + 55e7c08
Merge commit '55e7c08a83547424024bac311d5459cb82cf6dae' into glitch-soc/merge-upstream

Conflicts:
- `app/models/user_settings.rb`:
  Upstream added a constraint on a setting textually close
  to glitch-soc-only settings.
  Applied upstream's change.
- `lib/sanitize_ext/sanitize_config.rb`:
  Upstream added support for the `translate` attribute on a few elements,
  where glitch-soc had a different set of allowed elements and attributes.
  Extended glitch-soc's allowed attributes with `translate` as upstream did.
- `spec/validators/status_length_validator_spec.rb`:
  Upstream refactored to use RSpec's `instance_double` instead of `double`,
  but glitch-soc had changes to tests due to configurable max toot chars.
  Applied upstream's changes while keeping tests against configurable max
  toot chars.
116 files changed, 1223 insertions(+), 850 deletions(-)

M .rubocop_todo.yml
M Gemfile.lock
M app/controllers/admin/webhooks_controller.rb
M app/controllers/api/v1/conversations_controller.rb
M app/controllers/api/v1/statuses/histories_controller.rb
M app/controllers/api/v2/admin/accounts_controller.rb
M app/helpers/settings_helper.rb
A app/javascript/images/friends-cropped.png
M app/javascript/mastodon/components/account.jsx
R app/javascript/mastodon/components/{autosuggest_hashtag.jsx => autosuggest_hashtag.tsx}
M app/javascript/mastodon/components/autosuggest_input.jsx
M app/javascript/mastodon/components/autosuggest_textarea.jsx
M app/javascript/mastodon/components/verified_badge.tsx
M app/javascript/mastodon/features/bookmarked_statuses/index.jsx
M app/javascript/mastodon/features/community_timeline/index.jsx
M app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
M app/javascript/mastodon/features/explore/links.jsx
M app/javascript/mastodon/features/explore/statuses.jsx
M app/javascript/mastodon/features/explore/tags.jsx
M app/javascript/mastodon/features/favourited_statuses/index.jsx
A app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx
M app/javascript/mastodon/features/home_timeline/index.jsx
M app/javascript/mastodon/features/onboarding/components/step.jsx
M app/javascript/mastodon/features/onboarding/follows.jsx
M app/javascript/mastodon/features/onboarding/index.jsx
M app/javascript/mastodon/features/onboarding/share.jsx
M app/javascript/mastodon/features/pinned_statuses/index.jsx
M app/javascript/mastodon/features/public_timeline/index.jsx
M app/javascript/mastodon/features/ui/components/header.jsx
M app/javascript/mastodon/locales/en.json
M app/javascript/mastodon/selectors/index.js
M app/javascript/styles/mastodon-light/diff.scss
M app/javascript/styles/mastodon/components.scss
M app/lib/request_pool.rb
M app/lib/text_formatter.rb
M app/models/account_conversation.rb
M app/models/user_settings.rb
M app/models/webhook.rb
M app/policies/webhook_policy.rb
M app/services/remove_status_service.rb
M app/views/admin/webhooks/_form.html.haml
M app/workers/scheduler/user_cleanup_scheduler.rb
M config/boot.rb
M config/locales/activerecord.en.yml
M config/routes/api.rb
M lib/sanitize_ext/sanitize_config.rb
M spec/controllers/admin/change_emails_controller_spec.rb
M spec/controllers/admin/confirmations_controller_spec.rb
M spec/controllers/admin/disputes/appeals_controller_spec.rb
M spec/controllers/admin/domain_allows_controller_spec.rb
M spec/controllers/admin/domain_blocks_controller_spec.rb
M spec/controllers/admin/reports/actions_controller_spec.rb
M spec/controllers/admin/webhooks_controller_spec.rb
D spec/controllers/api/v1/admin/account_actions_controller_spec.rb
M spec/controllers/api/v1/conversations_controller_spec.rb
M spec/controllers/api/v1/notifications_controller_spec.rb
M spec/controllers/api/v1/reports_controller_spec.rb
M spec/controllers/api/v1/statuses/histories_controller_spec.rb
D spec/controllers/api/v1/suggestions_controller_spec.rb
D spec/controllers/api/v1/tags_controller_spec.rb
M spec/controllers/api/v2/admin/accounts_controller_spec.rb
M spec/controllers/api/web/embeds_controller_spec.rb
M spec/controllers/auth/sessions_controller_spec.rb
M spec/controllers/authorize_interactions_controller_spec.rb
M spec/controllers/disputes/appeals_controller_spec.rb
M spec/controllers/statuses_controller_spec.rb
M spec/helpers/statuses_helper_spec.rb
M spec/lib/activitypub/activity/add_spec.rb
M spec/lib/activitypub/activity/move_spec.rb
M spec/lib/request_pool_spec.rb
M spec/lib/request_spec.rb
M spec/lib/sanitize_config_spec.rb
M spec/lib/suspicious_sign_in_detector_spec.rb
M spec/mailers/user_mailer_spec.rb
M spec/models/account/field_spec.rb
M spec/models/account_migration_spec.rb
M spec/models/session_activation_spec.rb
M spec/models/setting_spec.rb
M spec/models/user_settings_spec.rb
M spec/policies/webhook_policy_spec.rb
A spec/requests/api/v1/admin/account_actions_spec.rb
A spec/requests/api/v1/suggestions_spec.rb
A spec/requests/api/v1/tags_spec.rb
M spec/services/account_search_service_spec.rb
M spec/services/backup_service_spec.rb
M spec/services/bootstrap_timeline_service_spec.rb
M spec/services/bulk_import_service_spec.rb
M spec/services/fetch_resource_service_spec.rb
M spec/services/import_service_spec.rb
M spec/services/post_status_service_spec.rb
M spec/services/resolve_url_service_spec.rb
M spec/services/search_service_spec.rb
M spec/services/unsuspend_account_service_spec.rb
M spec/validators/blacklisted_email_validator_spec.rb
M spec/validators/disallowed_hashtags_validator_spec.rb
M spec/validators/email_mx_validator_spec.rb
M spec/validators/follow_limit_validator_spec.rb
M spec/validators/note_length_validator_spec.rb
M spec/validators/poll_validator_spec.rb
M spec/validators/status_length_validator_spec.rb
M spec/validators/status_pin_validator_spec.rb
M spec/validators/unique_username_validator_spec.rb
M spec/validators/unreserved_username_validator_spec.rb
M spec/validators/url_validator_spec.rb
M spec/views/statuses/show.html.haml_spec.rb
M spec/workers/activitypub/processing_worker_spec.rb
M spec/workers/admin/domain_purge_worker_spec.rb
M spec/workers/domain_block_worker_spec.rb
M spec/workers/domain_clear_media_worker_spec.rb
M spec/workers/feed_insert_worker_spec.rb
M spec/workers/move_worker_spec.rb
M spec/workers/publish_scheduled_announcement_worker_spec.rb
M spec/workers/refollow_worker_spec.rb
M spec/workers/regeneration_worker_spec.rb
M spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
M yarn.lock
M .rubocop_todo.yml => .rubocop_todo.yml +0 -41
@@ 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'

M Gemfile.lock => Gemfile.lock +1 -1
@@ 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)

M app/controllers/admin/webhooks_controller.rb => app/controllers/admin/webhooks_controller.rb +4 -1
@@ 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


M app/controllers/api/v1/conversations_controller.rb => app/controllers/api/v1/conversations_controller.rb +5 -0
@@ 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

M app/controllers/api/v1/statuses/histories_controller.rb => app/controllers/api/v1/statuses/histories_controller.rb +5 -1
@@ 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?

M app/controllers/api/v2/admin/accounts_controller.rb => app/controllers/api/v2/admin/accounts_controller.rb +8 -0
@@ 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

M app/helpers/settings_helper.rb => app/helpers/settings_helper.rb +0 -9
@@ 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

A app/javascript/images/friends-cropped.png => app/javascript/images/friends-cropped.png +0 -0
M app/javascript/mastodon/components/account.jsx => app/javascript/mastodon/components/account.jsx +12 -2
@@ 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>
    );
  }

R app/javascript/mastodon/components/autosuggest_hashtag.jsx => app/javascript/mastodon/components/autosuggest_hashtag.tsx +34 -36
@@ 1,44 1,42 @@
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,
interface Props {
  tag: {
    name: string;
    url?: string;
    history?: Array<{
      uses: number;
      accounts: string;
      day: string;
    }>;
    following?: boolean;
    type: 'hashtag';
  };
}

  render() {
    const { tag } = this.props;
    const weeklyUses = tag.history && (
      <ShortNumber
        value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
      />
    );
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>
        )}
  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>
  );
};

M app/javascript/mastodon/components/autosuggest_input.jsx => app/javascript/mastodon/components/autosuggest_input.jsx +1 -1
@@ 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;

M app/javascript/mastodon/components/autosuggest_textarea.jsx => app/javascript/mastodon/components/autosuggest_textarea.jsx +1 -1
@@ 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;

M app/javascript/mastodon/components/verified_badge.tsx => app/javascript/mastodon/components/verified_badge.tsx +17 -1
@@ 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>
);

M app/javascript/mastodon/features/bookmarked_statuses/index.jsx => app/javascript/mastodon/features/bookmarked_statuses/index.jsx +2 -1
@@ 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']),
});

M app/javascript/mastodon/features/community_timeline/index.jsx => app/javascript/mastodon/features/community_timeline/index.jsx +1 -4
@@ 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' : ''}`}

M app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx => app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +1 -1
@@ 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>


M app/javascript/mastodon/features/explore/links.jsx => app/javascript/mastodon/features/explore/links.jsx +1 -1
@@ 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>
    );


M app/javascript/mastodon/features/explore/statuses.jsx => app/javascript/mastodon/features/explore/statuses.jsx +3 -2
@@ 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

M app/javascript/mastodon/features/explore/tags.jsx => app/javascript/mastodon/features/explore/tags.jsx +1 -1
@@ 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>
    );


M app/javascript/mastodon/features/favourited_statuses/index.jsx => app/javascript/mastodon/features/favourited_statuses/index.jsx +2 -1
@@ 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']),
});

A app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx => app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx +24 -0
@@ 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

M app/javascript/mastodon/features/home_timeline/index.jsx => app/javascript/mastodon/features/home_timeline/index.jsx +35 -4
@@ 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 />}

M app/javascript/mastodon/features/onboarding/components/step.jsx => app/javascript/mastodon/features/onboarding/components/step.jsx +5 -5
@@ 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>
    </>
  );


M app/javascript/mastodon/features/onboarding/follows.jsx => app/javascript/mastodon/features/onboarding/follows.jsx +6 -18
@@ 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>

M app/javascript/mastodon/features/onboarding/index.jsx => app/javascript/mastodon/features/onboarding/index.jsx +8 -6
@@ 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>


M app/javascript/mastodon/features/onboarding/share.jsx => app/javascript/mastodon/features/onboarding/share.jsx +2 -2
@@ 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>


M app/javascript/mastodon/features/pinned_statuses/index.jsx => app/javascript/mastodon/features/pinned_statuses/index.jsx +3 -1
@@ 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']),
});


M app/javascript/mastodon/features/public_timeline/index.jsx => app/javascript/mastodon/features/public_timeline/index.jsx +1 -4
@@ 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}

M app/javascript/mastodon/features/ui/components/header.jsx => app/javascript/mastodon/features/ui/components/header.jsx +10 -0
@@ 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;

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +22 -19
@@ 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!",

M app/javascript/mastodon/selectors/index.js => app/javascript/mastodon/selectors/index.js +4 -0
@@ 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());

M app/javascript/styles/mastodon-light/diff.scss => app/javascript/styles/mastodon-light/diff.scss +0 -5
@@ 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 {

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +112 -23
@@ 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;
    }
  }
}


M app/lib/request_pool.rb => app/lib/request_pool.rb +3 -2
@@ 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


M app/lib/text_formatter.rb => app/lib/text_formatter.rb +2 -2
@@ 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


M app/models/account_conversation.rb => app/models/account_conversation.rb +2 -8
@@ 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

M app/models/user_settings.rb => app/models/user_settings.rb +5 -2
@@ 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)

M app/models/webhook.rb => app/models/webhook.rb +22 -0
@@ 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?


M app/policies/webhook_policy.rb => app/policies/webhook_policy.rb +2 -2
@@ 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

M app/services/remove_status_service.rb => app/services/remove_status_service.rb +17 -1
@@ 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

M app/views/admin/webhooks/_form.html.haml => app/views/admin/webhooks/_form.html.haml +1 -1
@@ 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}}" }' }

M app/workers/scheduler/user_cleanup_scheduler.rb => app/workers/scheduler/user_cleanup_scheduler.rb +1 -1
@@ 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

M config/boot.rb => config/boot.rb +1 -9
@@ 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.

M config/locales/activerecord.en.yml => config/locales/activerecord.en.yml +4 -0
@@ 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

M config/routes/api.rb => config/routes/api.rb +1 -0
@@ 82,6 82,7 @@ namespace :api, format: false do
    resources :conversations, only: [:index, :destroy] do
      member do
        post :read
        post :unread
      end
    end


M lib/sanitize_ext/sanitize_config.rb => lib/sanitize_ext/sanitize_config.rb +10 -3
@@ 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,

M spec/controllers/admin/change_emails_controller_spec.rb => spec/controllers/admin/change_emails_controller_spec.rb +2 -1
@@ 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

M spec/controllers/admin/confirmations_controller_spec.rb => spec/controllers/admin/confirmations_controller_spec.rb +1 -1
@@ 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

M spec/controllers/admin/disputes/appeals_controller_spec.rb => spec/controllers/admin/disputes/appeals_controller_spec.rb +4 -2
@@ 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


M spec/controllers/admin/domain_allows_controller_spec.rb => spec/controllers/admin/domain_allows_controller_spec.rb +1 -1
@@ 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 }

M spec/controllers/admin/domain_blocks_controller_spec.rb => spec/controllers/admin/domain_blocks_controller_spec.rb +1 -1
@@ 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 }

M spec/controllers/admin/reports/actions_controller_spec.rb => spec/controllers/admin/reports/actions_controller_spec.rb +12 -18
@@ 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

M spec/controllers/admin/webhooks_controller_spec.rb => spec/controllers/admin/webhooks_controller_spec.rb +2 -2
@@ 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


D spec/controllers/api/v1/admin/account_actions_controller_spec.rb => spec/controllers/api/v1/admin/account_actions_controller_spec.rb +0 -55
@@ 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

M spec/controllers/api/v1/conversations_controller_spec.rb => spec/controllers/api/v1/conversations_controller_spec.rb +4 -2
@@ 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


M spec/controllers/api/v1/notifications_controller_spec.rb => spec/controllers/api/v1/notifications_controller_spec.rb +20 -31
@@ 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

M spec/controllers/api/v1/reports_controller_spec.rb => spec/controllers/api/v1/reports_controller_spec.rb +2 -1
@@ 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


M spec/controllers/api/v1/statuses/histories_controller_spec.rb => spec/controllers/api/v1/statuses/histories_controller_spec.rb +1 -0
@@ 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

D spec/controllers/api/v1/suggestions_controller_spec.rb => spec/controllers/api/v1/suggestions_controller_spec.rb +0 -37
@@ 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

D spec/controllers/api/v1/tags_controller_spec.rb => spec/controllers/api/v1/tags_controller_spec.rb +0 -88
@@ 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

M spec/controllers/api/v2/admin/accounts_controller_spec.rb => spec/controllers/api/v2/admin/accounts_controller_spec.rb +8 -0
@@ 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

M spec/controllers/api/web/embeds_controller_spec.rb => spec/controllers/api/web/embeds_controller_spec.rb +1 -1
@@ 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 }

M spec/controllers/auth/sessions_controller_spec.rb => spec/controllers/auth/sessions_controller_spec.rb +2 -1
@@ 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

M spec/controllers/authorize_interactions_controller_spec.rb => spec/controllers/authorize_interactions_controller_spec.rb +5 -5
@@ 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)

M spec/controllers/disputes/appeals_controller_spec.rb => spec/controllers/disputes/appeals_controller_spec.rb +2 -1
@@ 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


M spec/controllers/statuses_controller_spec.rb => spec/controllers/statuses_controller_spec.rb +19 -214
@@ 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_behaves_like 'cacheable response'

        it 'returns Link 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'
        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
          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_behaves_like 'cacheable response'

          it 'returns Link 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'
          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
            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

M spec/helpers/statuses_helper_spec.rb => spec/helpers/statuses_helper_spec.rb +17 -17
@@ 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'

M spec/lib/activitypub/activity/add_spec.rb => spec/lib/activitypub/activity/add_spec.rb +1 -1
@@ 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
        {

M spec/lib/activitypub/activity/move_spec.rb => spec/lib/activitypub/activity/move_spec.rb +1 -1
@@ 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

M spec/lib/request_pool_spec.rb => spec/lib/request_pool_spec.rb +16 -7
@@ 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!')
    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

      subject.with('http://example.com') do |http_client|
        http_client.get('/').flush
      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

      expect(subject.size).to eq 1
      sleep RequestPool::MAX_IDLE_TIME + 30 + 1
      expect(subject.size).to eq 0
      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

M spec/lib/request_spec.rb => spec/lib/request_spec.rb +2 -2
@@ 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)

M spec/lib/sanitize_config_spec.rb => spec/lib/sanitize_config_spec.rb +8 -0
@@ 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

M spec/lib/suspicious_sign_in_detector_spec.rb => spec/lib/suspicious_sign_in_detector_spec.rb +1 -1
@@ 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

M spec/mailers/user_mailer_spec.rb => spec/mailers/user_mailer_spec.rb +55 -0
@@ 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

M spec/models/account/field_spec.rb => spec/models/account/field_spec.rb +3 -3
@@ 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 }

M spec/models/account_migration_spec.rb => spec/models/account_migration_spec.rb +2 -2
@@ 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

M spec/models/session_activation_spec.rb => spec/models/session_activation_spec.rb +2 -2
@@ 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

M spec/models/setting_spec.rb => spec/models/setting_spec.rb +1 -1
@@ 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' } }

M spec/models/user_settings_spec.rb => spec/models/user_settings_spec.rb +10 -0
@@ 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

M spec/policies/webhook_policy_spec.rb => spec/policies/webhook_policy_spec.rb +19 -3
@@ 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

A spec/requests/api/v1/admin/account_actions_spec.rb => spec/requests/api/v1/admin/account_actions_spec.rb +154 -0
@@ 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

A spec/requests/api/v1/suggestions_spec.rb => spec/requests/api/v1/suggestions_spec.rb +103 -0
@@ 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

A spec/requests/api/v1/tags_spec.rb => spec/requests/api/v1/tags_spec.rb +169 -0
@@ 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

M spec/services/account_search_service_spec.rb => spec/services/account_search_service_spec.rb +2 -2
@@ 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)

M spec/services/backup_service_spec.rb => spec/services/backup_service_spec.rb +47 -34
@@ 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({
      'type' => 'Create',
      'object' => include({
        'id' => ActivityPub::TagManager.instance.uri_for(status),
        'content' => '<p>Hello</p>',
      }),
    })
    expect(json['orderedItems'][1]).to include({
      'type' => 'Create',
      'object' => include({
        'id' => ActivityPub::TagManager.instance.uri_for(private_status),
        'content' => '<p>secret</p>',
      }),
    })
  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

  it 'exports likes.json as expected' do
    service_call
  def expect_bookmarks_export
    json = export_json(:bookmarks)

    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)]
    aggregate_failures do
      expect(json['type']).to eq 'OrderedCollection'
      expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
    end
  end

  it 'exports bookmarks.json as expected' do
    service_call
  def export_json(type)
    Oj.load(read_zip_file(backup, "#{type}.json"))
  end

    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)]
  def include_create_item(status)
    include({
      'type' => 'Create',
      'object' => include({
        'id' => ActivityPub::TagManager.instance.uri_for(status),
        'content' => "<p>#{status.text}</p>",
      }),
    })
  end
end

M spec/services/bootstrap_timeline_service_spec.rb => spec/services/bootstrap_timeline_service_spec.rb +1 -1
@@ 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) }

M spec/services/bulk_import_service_spec.rb => spec/services/bulk_import_service_spec.rb +8 -8
@@ 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) }

M spec/services/fetch_resource_service_spec.rb => spec/services/fetch_resource_service_spec.rb +2 -2
@@ 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)

M spec/services/import_service_spec.rb => spec/services/import_service_spec.rb +1 -1
@@ 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')

M spec/services/post_status_service_spec.rb => spec/services/post_status_service_spec.rb +2 -2
@@ 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)

M spec/services/resolve_url_service_spec.rb => spec/services/resolve_url_service_spec.rb +2 -2
@@ 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)

M spec/services/search_service_spec.rb => spec/services/search_service_spec.rb +4 -4
@@ 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)

M spec/services/unsuspend_account_service_spec.rb => spec/services/unsuspend_account_service_spec.rb +1 -1
@@ 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)

M spec/validators/blacklisted_email_validator_spec.rb => spec/validators/blacklisted_email_validator_spec.rb +2 -2
@@ 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)

M spec/validators/disallowed_hashtags_validator_spec.rb => spec/validators/disallowed_hashtags_validator_spec.rb +2 -2
@@ 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 }

M spec/validators/email_mx_validator_spec.rb => spec/validators/email_mx_validator_spec.rb +18 -14
@@ 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)


M spec/validators/follow_limit_validator_spec.rb => spec/validators/follow_limit_validator_spec.rb +3 -3
@@ 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 }


M spec/validators/note_length_validator_spec.rb => spec/validators/note_length_validator_spec.rb +9 -3
@@ 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

M spec/validators/poll_validator_spec.rb => spec/validators/poll_validator_spec.rb +2 -2
@@ 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 }


M spec/validators/status_length_validator_spec.rb => spec/validators/status_length_validator_spec.rb +16 -10
@@ 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

M spec/validators/status_pin_validator_spec.rb => spec/validators/status_pin_validator_spec.rb +5 -5
@@ 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' }

M spec/validators/unique_username_validator_spec.rb => spec/validators/unique_username_validator_spec.rb +13 -7
@@ 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

M spec/validators/unreserved_username_validator_spec.rb => spec/validators/unreserved_username_validator_spec.rb +2 -2
@@ 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 }

M spec/validators/url_validator_spec.rb => spec/validators/url_validator_spec.rb +2 -2
@@ 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 }


M spec/views/statuses/show.html.haml_spec.rb => spec/views/statuses/show.html.haml_spec.rb +1 -1
@@ 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')

M spec/workers/activitypub/processing_worker_spec.rb => spec/workers/activitypub/processing_worker_spec.rb +2 -1
@@ 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

M spec/workers/admin/domain_purge_worker_spec.rb => spec/workers/admin/domain_purge_worker_spec.rb +1 -1
@@ 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')


M spec/workers/domain_block_worker_spec.rb => spec/workers/domain_block_worker_spec.rb +1 -1
@@ 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)


M spec/workers/domain_clear_media_worker_spec.rb => spec/workers/domain_clear_media_worker_spec.rb +1 -1
@@ 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)


M spec/workers/feed_insert_worker_spec.rb => spec/workers/feed_insert_worker_spec.rb +4 -4
@@ 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)


M spec/workers/move_worker_spec.rb => spec/workers/move_worker_spec.rb +1 -1
@@ 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)

M spec/workers/publish_scheduled_announcement_worker_spec.rb => spec/workers/publish_scheduled_announcement_worker_spec.rb +1 -1
@@ 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 }


M spec/workers/refollow_worker_spec.rb => spec/workers/refollow_worker_spec.rb +1 -1
@@ 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)

M spec/workers/regeneration_worker_spec.rb => spec/workers/regeneration_worker_spec.rb +1 -1
@@ 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)


M spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb => spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb +6 -0
@@ 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)

M yarn.lock => yarn.lock +6 -3
@@ 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==