~cytrogen/masto-fe

e2ab9d4dad3f2c298fc6222b0e424ac20bbc017a — Claire 2 years ago 3b375ee + e387175
Merge commit 'e387175fc9a3ebfd72ab45ebfe43ecfabef7b0c3' into glitch-soc/merge-upstream
46 files changed, 570 insertions(+), 303 deletions(-)

M .rubocop_todo.yml
M Gemfile
M Gemfile.lock
M app/controllers/api/v1/featured_tags_controller.rb
M app/javascript/mastodon/components/admin/Counter.jsx
M app/javascript/mastodon/components/admin/Dimension.jsx
M app/javascript/mastodon/components/display_name.tsx
M app/javascript/mastodon/components/empty_account.tsx
M app/javascript/mastodon/components/hashtag.jsx
M app/javascript/mastodon/components/server_banner.jsx
D app/javascript/mastodon/components/skeleton.jsx
A app/javascript/mastodon/components/skeleton.tsx
D app/javascript/mastodon/components/timeline_hint.jsx
A app/javascript/mastodon/components/timeline_hint.tsx
M app/javascript/mastodon/features/about/index.jsx
M app/javascript/mastodon/features/account_timeline/index.jsx
M app/javascript/mastodon/features/explore/components/story.jsx
M app/javascript/mastodon/features/followers/index.jsx
M app/javascript/mastodon/features/following/index.jsx
M app/javascript/mastodon/features/privacy_policy/index.jsx
M app/javascript/mastodon/features/ui/components/embed_modal.jsx
M app/lib/admin/metrics/dimension.rb
M app/lib/admin/metrics/measure.rb
M app/lib/extractor.rb
M app/lib/feed_manager.rb
M app/models/account.rb
M app/models/account_statuses_cleanup_policy.rb
M app/models/account_suggestions/setting_source.rb
M app/models/account_suggestions/source.rb
M app/models/follow_recommendation_filter.rb
M app/models/notification.rb
M app/models/user_role.rb
M app/models/webhook.rb
M app/services/process_mentions_service.rb
M app/validators/existing_username_validator.rb
M app/views/auth/confirmations/captcha.html.haml
M db/migrate/20200407202420_migrate_unavailable_inboxes.rb
M package.json
M spec/controllers/admin/announcements_controller_spec.rb
D spec/controllers/api/v1/featured_tags_controller_spec.rb
M spec/fabricators/featured_tag_fabricator.rb
A spec/features/captcha_spec.rb
M spec/policies/status_policy_spec.rb
M spec/presenters/status_relationships_presenter_spec.rb
A spec/requests/api/v1/featured_tags_spec.rb
M yarn.lock
M .rubocop_todo.yml => .rubocop_todo.yml +0 -29
@@ 240,31 240,6 @@ Naming/VariableNumber:
    - 'spec/services/activitypub/fetch_featured_collection_service_spec.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
Performance/MapCompact:
  Exclude:
    - 'app/lib/admin/metrics/dimension.rb'
    - 'app/lib/admin/metrics/measure.rb'
    - 'app/lib/feed_manager.rb'
    - 'app/models/account.rb'
    - 'app/models/account_statuses_cleanup_policy.rb'
    - 'app/models/account_suggestions/setting_source.rb'
    - 'app/models/account_suggestions/source.rb'
    - 'app/models/follow_recommendation_filter.rb'
    - 'app/models/notification.rb'
    - 'app/models/user_role.rb'
    - 'app/models/webhook.rb'
    - 'app/services/process_mentions_service.rb'
    - 'app/validators/existing_username_validator.rb'
    - 'db/migrate/20200407202420_migrate_unavailable_inboxes.rb'
    - 'spec/presenters/status_relationships_presenter_spec.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: SafeMultiline.
Performance/StartWith:
  Exclude:
    - 'app/lib/extractor.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
Performance/UnfreezeString:
  Exclude:
    - 'app/lib/rss/builder.rb'


@@ 599,10 574,6 @@ RSpec/PredicateMatcher:
    - 'spec/models/user_spec.rb'
    - 'spec/services/post_status_service_spec.rb'

RSpec/RepeatedExample:
  Exclude:
    - 'spec/policies/status_policy_spec.rb'

RSpec/StubbedMock:
  Exclude:
    - 'spec/controllers/api/base_controller_spec.rb'

M Gemfile => Gemfile +2 -2
@@ 17,7 17,7 @@ gem 'makara', '~> 0.5'
gem 'pghero'
gem 'dotenv-rails', '~> 2.8'

gem 'aws-sdk-s3', '~> 1.120', require: false
gem 'aws-sdk-s3', '~> 1.122', require: false
gem 'fog-core', '<= 2.4.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'kt-paperclip', '~> 7.1', github: 'kreeti/kt-paperclip', ref: '11abf222dc31bff71160a1d138b445214f434b2b'


@@ 75,7 75,7 @@ gem 'rails-settings-cached', '~> 0.6', git: 'https://github.com/mastodon/rails-s
gem 'redcarpet', '~> 3.6'
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 2.1'
gem 'rqrcode', '~> 2.2'
gem 'ruby-progressbar', '~> 1.13'
gem 'sanitize', '~> 6.0'
gem 'scenic', '~> 1.7'

M Gemfile.lock => Gemfile.lock +19 -19
@@ 109,16 109,16 @@ GEM
    attr_required (1.0.1)
    awrence (1.2.1)
    aws-eventstream (1.2.0)
    aws-partitions (1.752.0)
    aws-sdk-core (3.171.0)
    aws-partitions (1.761.0)
    aws-sdk-core (3.172.0)
      aws-eventstream (~> 1, >= 1.0.2)
      aws-partitions (~> 1, >= 1.651.0)
      aws-sigv4 (~> 1.5)
      jmespath (~> 1, >= 1.6.1)
    aws-sdk-kms (1.63.0)
    aws-sdk-kms (1.64.0)
      aws-sdk-core (~> 3, >= 3.165.0)
      aws-sigv4 (~> 1.1)
    aws-sdk-s3 (1.121.0)
    aws-sdk-s3 (1.122.0)
      aws-sdk-core (~> 3, >= 3.165.0)
      aws-sdk-kms (~> 1)
      aws-sigv4 (~> 1.4)


@@ 189,7 189,7 @@ GEM
    coderay (1.1.3)
    color_diff (0.1)
    concurrent-ruby (1.2.2)
    connection_pool (2.4.0)
    connection_pool (2.4.1)
    cose (1.3.0)
      cbor (~> 0.5.9)
      openssl-signature_algorithm (~> 1.0)


@@ 398,9 398,9 @@ GEM
      activesupport (>= 4)
      railties (>= 4)
      request_store (~> 1.0)
    loofah (2.20.0)
    loofah (2.21.3)
      crass (~> 1.0.2)
      nokogiri (>= 1.5.9)
      nokogiri (>= 1.12.0)
    mail (2.8.1)
      mini_mime (>= 0.1.1)
      net-imap


@@ 576,7 576,7 @@ GEM
    rexml (3.2.5)
    rotp (6.2.2)
    rpam2 (4.0.2)
    rqrcode (2.1.2)
    rqrcode (2.2.0)
      chunky_png (~> 1.0)
      rqrcode_core (~> 1.0)
    rqrcode_core (1.2.0)


@@ 588,20 588,20 @@ GEM
    rspec-mocks (3.12.5)
      diff-lcs (>= 1.2.0, < 2.0)
      rspec-support (~> 3.12.0)
    rspec-rails (6.0.1)
    rspec-rails (6.0.2)
      actionpack (>= 6.1)
      activesupport (>= 6.1)
      railties (>= 6.1)
      rspec-core (~> 3.11)
      rspec-expectations (~> 3.11)
      rspec-mocks (~> 3.11)
      rspec-support (~> 3.11)
      rspec-core (~> 3.12)
      rspec-expectations (~> 3.12)
      rspec-mocks (~> 3.12)
      rspec-support (~> 3.12)
    rspec-sidekiq (3.1.0)
      rspec-core (~> 3.0, >= 3.0.0)
      sidekiq (>= 2.4.0)
    rspec-support (3.12.0)
    rspec_chunked (0.6)
    rubocop (1.50.2)
    rubocop (1.51.0)
      json (~> 2.3)
      parallel (~> 1.10)
      parser (>= 3.2.0.0)


@@ 611,11 611,11 @@ GEM
      rubocop-ast (>= 1.28.0, < 2.0)
      ruby-progressbar (~> 1.7)
      unicode-display_width (>= 2.4.0, < 3.0)
    rubocop-ast (1.28.0)
    rubocop-ast (1.28.1)
      parser (>= 3.2.1.0)
    rubocop-capybara (2.18.0)
      rubocop (~> 1.41)
    rubocop-performance (1.17.1)
    rubocop-performance (1.18.0)
      rubocop (>= 1.7.0, < 2.0)
      rubocop-ast (>= 0.4.0)
    rubocop-rails (2.19.1)


@@ 761,7 761,7 @@ GEM
    xorcist (1.1.3)
    xpath (3.2.0)
      nokogiri (~> 1.8)
    zeitwerk (2.6.7)
    zeitwerk (2.6.8)

PLATFORMS
  ruby


@@ 770,7 770,7 @@ DEPENDENCIES
  active_model_serializers (~> 0.10)
  addressable (~> 2.8)
  annotate (~> 3.2)
  aws-sdk-s3 (~> 1.120)
  aws-sdk-s3 (~> 1.122)
  better_errors (~> 2.9)
  binding_of_caller (~> 1.0)
  blurhash (~> 0.1)


@@ 860,7 860,7 @@ DEPENDENCIES
  redcarpet (~> 3.6)
  redis (~> 4.5)
  redis-namespace (~> 1.10)
  rqrcode (~> 2.1)
  rqrcode (~> 2.2)
  rspec-rails (~> 6.0)
  rspec-sidekiq (~> 3.1)
  rspec_chunked (~> 0.6)

M app/controllers/api/v1/featured_tags_controller.rb => app/controllers/api/v1/featured_tags_controller.rb +2 -2
@@ 13,7 13,7 @@ class Api::V1::FeaturedTagsController < Api::BaseController
  end

  def create
    featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name])
    featured_tag = CreateFeaturedTagService.new.call(current_account, params.require(:name))
    render json: featured_tag, serializer: REST::FeaturedTagSerializer
  end



@@ 33,6 33,6 @@ class Api::V1::FeaturedTagsController < Api::BaseController
  end

  def featured_tag_params
    params.permit(:name)
    params.require(:name)
  end
end

M app/javascript/mastodon/components/admin/Counter.jsx => app/javascript/mastodon/components/admin/Counter.jsx +1 -1
@@ 4,7 4,7 @@ import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';

const percIncrease = (a, b) => {
  let percent;

M app/javascript/mastodon/components/admin/Dimension.jsx => app/javascript/mastodon/components/admin/Dimension.jsx +1 -1
@@ 3,7 3,7 @@ import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { roundTo10 } from 'mastodon/utils/numbers';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';

export default class Dimension extends React.PureComponent {


M app/javascript/mastodon/components/display_name.tsx => app/javascript/mastodon/components/display_name.tsx +1 -1
@@ 5,7 5,7 @@ import type { List } from 'immutable';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';

import Skeleton from './skeleton';
import { Skeleton } from './skeleton';

interface Props {
  account?: Account;

M app/javascript/mastodon/components/empty_account.tsx => app/javascript/mastodon/components/empty_account.tsx +1 -1
@@ 3,7 3,7 @@ import React from 'react';
import classNames from 'classnames';

import { DisplayName } from 'mastodon/components/display_name';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';

interface Props {
  size?: number;

M app/javascript/mastodon/components/hashtag.jsx => app/javascript/mastodon/components/hashtag.jsx +1 -1
@@ 6,7 6,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Link } from 'react-router-dom';
import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';
import classNames from 'classnames';

class SilentErrorBoundary extends React.Component {

M app/javascript/mastodon/components/server_banner.jsx => app/javascript/mastodon/components/server_banner.jsx +1 -1
@@ 4,7 4,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { fetchServer } from 'mastodon/actions/server';
import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';
import Account from 'mastodon/containers/account_container';
import { domain } from 'mastodon/initial_state';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';

D app/javascript/mastodon/components/skeleton.jsx => app/javascript/mastodon/components/skeleton.jsx +0 -11
@@ 1,11 0,0 @@
import React from 'react';
import PropTypes from 'prop-types';

const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;

Skeleton.propTypes = {
  width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
};

export default Skeleton;

A app/javascript/mastodon/components/skeleton.tsx => app/javascript/mastodon/components/skeleton.tsx +12 -0
@@ 0,0 1,12 @@
import React from 'react';

interface Props {
  width?: number | string;
  height?: number | string;
}

export const Skeleton: React.FC<Props> = ({ width, height }) => (
  <span className='skeleton' style={{ width, height }}>
    &zwnj;
  </span>
);

D app/javascript/mastodon/components/timeline_hint.jsx => app/javascript/mastodon/components/timeline_hint.jsx +0 -18
@@ 1,18 0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';

const TimelineHint = ({ resource, url }) => (
  <div className='timeline-hint'>
    <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
    <br />
    <a href={url} target='_blank' rel='noopener'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
  </div>
);

TimelineHint.propTypes = {
  resource: PropTypes.node.isRequired,
  url: PropTypes.string.isRequired,
};

export default TimelineHint;

A app/javascript/mastodon/components/timeline_hint.tsx => app/javascript/mastodon/components/timeline_hint.tsx +27 -0
@@ 0,0 1,27 @@
import React from 'react';

import { FormattedMessage } from 'react-intl';

interface Props {
  resource: JSX.Element;
  url: string;
}

export const TimelineHint: React.FC<Props> = ({ resource, url }) => (
  <div className='timeline-hint'>
    <strong>
      <FormattedMessage
        id='timeline_hint.remote_resource_not_displayed'
        defaultMessage='{resource} from other servers are not displayed.'
        values={{ resource }}
      />
    </strong>
    <br />
    <a href={url} target='_blank' rel='noopener noreferrer'>
      <FormattedMessage
        id='account.browse_more_on_origin_server'
        defaultMessage='Browse more on the original profile'
      />
    </a>
  </div>
);

M app/javascript/mastodon/features/about/index.jsx => app/javascript/mastodon/features/about/index.jsx +1 -1
@@ 8,7 8,7 @@ import LinkFooter from 'mastodon/features/ui/components/link_footer';
import { Helmet } from 'react-helmet';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
import Account from 'mastodon/containers/account_container';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';
import { Icon }  from 'mastodon/components/icon';
import classNames from 'classnames';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';

M app/javascript/mastodon/features/account_timeline/index.jsx => app/javascript/mastodon/features/account_timeline/index.jsx +1 -1
@@ 12,7 12,7 @@ import ColumnBackButton from '../../components/column_back_button';
import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import TimelineHint from 'mastodon/components/timeline_hint';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import { me } from 'mastodon/initial_state';
import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'mastodon/selectors';

M app/javascript/mastodon/features/explore/components/story.jsx => app/javascript/mastodon/features/explore/components/story.jsx +1 -1
@@ 3,7 3,7 @@ import PropTypes from 'prop-types';
import { Blurhash } from 'mastodon/components/blurhash';
import { accountsCountRenderer } from 'mastodon/components/hashtag';
import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';
import classNames from 'classnames';

export default class Story extends React.PureComponent {

M app/javascript/mastodon/features/followers/index.jsx => app/javascript/mastodon/features/followers/index.jsx +1 -1
@@ 17,7 17,7 @@ import Column from '../ui/components/column';
import HeaderContainer from '../account_timeline/containers/header_container';
import ColumnBackButton from '../../components/column_back_button';
import ScrollableList from '../../components/scrollable_list';
import TimelineHint from 'mastodon/components/timeline_hint';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'mastodon/selectors';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';

M app/javascript/mastodon/features/following/index.jsx => app/javascript/mastodon/features/following/index.jsx +1 -1
@@ 17,7 17,7 @@ import Column from '../ui/components/column';
import HeaderContainer from '../account_timeline/containers/header_container';
import ColumnBackButton from '../../components/column_back_button';
import ScrollableList from '../../components/scrollable_list';
import TimelineHint from 'mastodon/components/timeline_hint';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'mastodon/selectors';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';

M app/javascript/mastodon/features/privacy_policy/index.jsx => app/javascript/mastodon/features/privacy_policy/index.jsx +1 -1
@@ 4,7 4,7 @@ import { Helmet } from 'react-helmet';
import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl';
import Column from 'mastodon/components/column';
import api from 'mastodon/api';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';

const messages = defineMessages({
  title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },

M app/javascript/mastodon/features/ui/components/embed_modal.jsx => app/javascript/mastodon/features/ui/components/embed_modal.jsx +1 -1
@@ 85,7 85,7 @@ class EmbedModal extends ImmutablePureComponent {
            className='embed-modal__iframe'
            frameBorder='0'
            ref={this.setIframeRef}
            sandbox='allow-same-origin'
            sandbox='allow-scripts allow-same-origin'
            title='preview'
          />
        </div>

M app/lib/admin/metrics/dimension.rb => app/lib/admin/metrics/dimension.rb +2 -2
@@ 14,9 14,9 @@ class Admin::Metrics::Dimension
  }.freeze

  def self.retrieve(dimension_keys, start_at, end_at, limit, params)
    Array(dimension_keys).map do |key|
    Array(dimension_keys).filter_map do |key|
      klass = DIMENSIONS[key.to_sym]
      klass&.new(start_at, end_at, limit, klass.with_params? ? params.require(key.to_sym) : nil)
    end.compact
    end
  end
end

M app/lib/admin/metrics/measure.rb => app/lib/admin/metrics/measure.rb +2 -2
@@ 19,9 19,9 @@ class Admin::Metrics::Measure
  }.freeze

  def self.retrieve(measure_keys, start_at, end_at, params)
    Array(measure_keys).map do |key|
    Array(measure_keys).filter_map do |key|
      klass = MEASURES[key.to_sym]
      klass&.new(start_at, end_at, klass.with_params? ? params.require(key.to_sym) : nil)
    end.compact
    end
  end
end

M app/lib/extractor.rb => app/lib/extractor.rb +1 -1
@@ 64,7 64,7 @@ module Extractor
      end_position   = match_data.char_end(1)
      after          = ::Regexp.last_match.post_match

      if %r{\A://}.match?(after)
      if after.start_with?('://')
        hash_text.match(/(.+)(https?\Z)/) do |matched|
          hash_text     = matched[1]
          end_position -= matched[2].codepoint_length

M app/lib/feed_manager.rb => app/lib/feed_manager.rb +4 -4
@@ 213,7 213,7 @@ class FeedManager
    timeline_key        = key(:home, account.id)
    timeline_status_ids = redis.zrange(timeline_key, 0, -1)
    statuses            = Status.where(id: timeline_status_ids).select(:id, :reblog_of_id, :account_id).to_a
    reblogged_ids       = Status.where(id: statuses.map(&:reblog_of_id).compact, account: target_account).pluck(:id)
    reblogged_ids       = Status.where(id: statuses.filter_map(&:reblog_of_id), account: target_account).pluck(:id)
    with_mentions_ids   = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact, account: target_account).pluck(:status_id)

    target_statuses = statuses.select do |status|


@@ 233,7 233,7 @@ class FeedManager
    timeline_key        = key(:list, list.id)
    timeline_status_ids = redis.zrange(timeline_key, 0, -1)
    statuses            = Status.where(id: timeline_status_ids).select(:id, :reblog_of_id, :account_id).to_a
    reblogged_ids       = Status.where(id: statuses.map(&:reblog_of_id).compact, account: target_account).pluck(:id)
    reblogged_ids       = Status.where(id: statuses.filter_map(&:reblog_of_id), account: target_account).pluck(:id)
    with_mentions_ids   = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact, account: target_account).pluck(:status_id)

    target_statuses = statuses.select do |status|


@@ 603,9 603,9 @@ class FeedManager
      arr
    end

    crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:in_reply_to_account_id).compact).pluck(:target_account_id).index_with(true)
    crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).pluck(:target_account_id).index_with(true)
    crutches[:languages]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h
    crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.map { |s| s.account_id if s.reblog? }.compact, show_reblogs: false).pluck(:target_account_id).index_with(true)
    crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map { |s| s.account_id if s.reblog? }, show_reblogs: false).pluck(:target_account_id).index_with(true)
    crutches[:blocking]        = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
    crutches[:muting]          = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
    crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.flat_map { |s| [s.account.domain, s.reblog&.account&.domain] }.compact).pluck(:domain).index_with(true)

M app/models/account.rb => app/models/account.rb +2 -2
@@ 299,11 299,11 @@ class Account < ApplicationRecord
  end

  def fields
    (self[:fields] || []).map do |f|
    (self[:fields] || []).filter_map do |f|
      Account::Field.new(self, f)
    rescue
      nil
    end.compact
    end
  end

  def fields_attributes=(attributes)

M app/models/account_statuses_cleanup_policy.rb => app/models/account_statuses_cleanup_policy.rb +2 -2
@@ 117,12 117,12 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
  private

  def update_last_inspected
    if EXCEPTION_BOOLS.map { |name| attribute_change_to_be_saved(name) }.compact.include?([true, false])
    if EXCEPTION_BOOLS.filter_map { |name| attribute_change_to_be_saved(name) }.include?([true, false])
      # Policy has been widened in such a way that any previously-inspected status
      # may need to be deleted, so we'll have to start again.
      redis.del("account_cleanup:#{account_id}")
    end
    redis.del("account_cleanup:#{account_id}") if EXCEPTION_THRESHOLDS.map { |name| attribute_change_to_be_saved(name) }.compact.any? { |old, new| old.present? && (new.nil? || new > old) }
    redis.del("account_cleanup:#{account_id}") if EXCEPTION_THRESHOLDS.filter_map { |name| attribute_change_to_be_saved(name) }.any? { |old, new| old.present? && (new.nil? || new > old) }
  end

  def validate_local_account

M app/models/account_suggestions/setting_source.rb => app/models/account_suggestions/setting_source.rb +2 -2
@@ 48,14 48,14 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
  end

  def setting_to_usernames_and_domains
    setting.split(',').map do |str|
    setting.split(',').filter_map do |str|
      username, domain = str.strip.gsub(/\A@/, '').split('@', 2)
      domain           = nil if TagManager.instance.local_domain?(domain)

      next if username.blank?

      [username.downcase, domain&.downcase]
    end.compact
    end
  end

  def setting

M app/models/account_suggestions/source.rb => app/models/account_suggestions/source.rb +1 -1
@@ 20,7 20,7 @@ class AccountSuggestions::Source

    map = scope.index_by { |account| to_ordered_list_key(account) }

    ordered_list.map { |ordered_list_key| map[ordered_list_key] }.compact.map do |account|
    ordered_list.filter_map { |ordered_list_key| map[ordered_list_key] }.map do |account|
      AccountSuggestions::Suggestion.new(
        account: account,
        source: key

M app/models/follow_recommendation_filter.rb => app/models/follow_recommendation_filter.rb +1 -1
@@ 22,7 22,7 @@ class FollowRecommendationFilter
      account_ids = redis.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i)
      accounts    = Account.where(id: account_ids).index_by(&:id)

      account_ids.map { |id| accounts[id] }.compact
      account_ids.filter_map { |id| accounts[id] }
    end
  end
end

M app/models/notification.rb => app/models/notification.rb +1 -1
@@ 114,7 114,7 @@ class Notification < ApplicationRecord
        ActiveRecord::Associations::Preloader.new.preload(grouped_notifications, associations)
      end

      unique_target_statuses = notifications.map(&:target_status).compact.uniq
      unique_target_statuses = notifications.filter_map(&:target_status).uniq
      # Call cache_collection in block
      cached_statuses_by_id = yield(unique_target_statuses).index_by(&:id)


M app/models/user_role.rb => app/models/user_role.rb +1 -1
@@ 125,7 125,7 @@ class UserRole < ApplicationRecord
  end

  def permissions_as_keys=(value)
    self.permissions = value.map(&:presence).compact.reduce(Flags::NONE) { |bitmask, privilege| FLAGS.key?(privilege.to_sym) ? (bitmask | FLAGS[privilege.to_sym]) : bitmask }
    self.permissions = value.filter_map(&:presence).reduce(Flags::NONE) { |bitmask, privilege| FLAGS.key?(privilege.to_sym) ? (bitmask | FLAGS[privilege.to_sym]) : bitmask }
  end

  def can?(*any_of_privileges)

M app/models/webhook.rb => app/models/webhook.rb +1 -1
@@ 53,7 53,7 @@ class Webhook < ApplicationRecord
  end

  def strip_events
    self.events = events.map { |str| str.strip.presence }.compact if events.present?
    self.events = events.filter_map { |str| str.strip.presence } if events.present?
  end

  def generate_secret

M app/services/process_mentions_service.rb => app/services/process_mentions_service.rb +1 -1
@@ 68,7 68,7 @@ class ProcessMentionsService < BaseService
  def assign_mentions!
    # Make sure we never mention blocked accounts
    unless @current_mentions.empty?
      mentioned_domains = @current_mentions.map { |m| m.account.domain }.compact.uniq
      mentioned_domains = @current_mentions.filter_map { |m| m.account.domain }.uniq
      blocked_domains   = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains))
      mentioned_account_ids = @current_mentions.map(&:account_id)
      blocked_account_ids = Set.new(@status.account.block_relationships.where(target_account_id: mentioned_account_ids).pluck(:target_account_id))

M app/validators/existing_username_validator.rb => app/validators/existing_username_validator.rb +2 -2
@@ 4,14 4,14 @@ class ExistingUsernameValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.blank?

    usernames_and_domains = value.split(',').map do |str|
    usernames_and_domains = value.split(',').filter_map do |str|
      username, domain = str.strip.gsub(/\A@/, '').split('@', 2)
      domain = nil if TagManager.instance.local_domain?(domain)

      next if username.blank?

      [str, username, domain]
    end.compact
    end

    usernames_with_no_accounts = usernames_and_domains.filter_map do |(str, username, domain)|
      str unless Account.find_remote(username, domain)

M app/views/auth/confirmations/captcha.html.haml => app/views/auth/confirmations/captcha.html.haml +1 -0
@@ 5,6 5,7 @@
  = render 'auth/shared/progress', stage: 'confirm'

  = hidden_field_tag :confirmation_token, params[:confirmation_token]
  = hidden_field_tag :redirect_to_app, params[:redirect_to_app]

  %p.lead= t('auth.captcha_confirmation.hint_html')


M db/migrate/20200407202420_migrate_unavailable_inboxes.rb => db/migrate/20200407202420_migrate_unavailable_inboxes.rb +2 -2
@@ 5,9 5,9 @@ class MigrateUnavailableInboxes < ActiveRecord::Migration[5.2]
    redis = RedisConfiguration.pool.checkout
    urls = redis.smembers('unavailable_inboxes')

    hosts = urls.map do |url|
    hosts = urls.filter_map do |url|
      Addressable::URI.parse(url).normalized_host
    end.compact.uniq
    end.uniq

    UnavailableDomain.delete_all


M package.json => package.json +7 -7
@@ 67,7 67,7 @@
    "file-loader": "^6.2.0",
    "font-awesome": "^4.7.0",
    "fuzzysort": "^2.0.4",
    "glob": "^10.2.2",
    "glob": "^10.2.6",
    "history": "^4.10.1",
    "http-link-header": "^1.1.1",
    "immutable": "^4.3.0",


@@ 116,7 116,7 @@
    "regenerator-runtime": "^0.13.11",
    "requestidlecallback": "^0.3.0",
    "reselect": "^4.1.8",
    "rimraf": "^5.0.0",
    "rimraf": "^5.0.1",
    "sass": "^1.62.1",
    "sass-loader": "^10.2.0",
    "stacktrace-js": "^2.0.2",


@@ 131,7 131,7 @@
    "webpack-assets-manifest": "^4.0.6",
    "webpack-bundle-analyzer": "^4.8.0",
    "webpack-cli": "^3.3.12",
    "webpack-merge": "^5.8.0",
    "webpack-merge": "^5.9.0",
    "wicg-inert": "^3.1.2",
    "workbox-expiration": "^6.5.4",
    "workbox-precaching": "^6.5.4",


@@ 178,8 178,8 @@
    "@types/uuid": "^9.0.0",
    "@types/webpack": "^4.41.33",
    "@types/yargs": "^17.0.24",
    "@typescript-eslint/eslint-plugin": "^5.59.6",
    "@typescript-eslint/parser": "^5.59.6",
    "@typescript-eslint/eslint-plugin": "^5.59.7",
    "@typescript-eslint/parser": "^5.59.7",
    "babel-jest": "^29.5.0",
    "eslint": "^8.40.0",
    "eslint-config-prettier": "^8.8.0",


@@ 199,7 199,7 @@
    "prettier": "^2.8.8",
    "react-intl-translations-manager": "^5.0.3",
    "react-test-renderer": "^18.2.0",
    "stylelint": "^15.6.1",
    "stylelint": "^15.6.2",
    "stylelint-config-standard-scss": "^9.0.0",
    "typescript": "^5.0.4",
    "webpack-dev-server": "^3.11.3",


@@ 216,7 216,7 @@
  },
  "lint-staged": {
    "*": "prettier --ignore-unknown --write",
    "Capfile|Gemfile|*.{rb,ruby,ru,rake}": "bundle exec rubocop -a",
    "Capfile|Gemfile|*.{rb,ruby,ru,rake}": "bundle exec rubocop --force-exclusion -a",
    "*.{js,jsx,ts,tsx}": "eslint --fix",
    "*.{css,scss}": "stylelint --fix"
  }

M spec/controllers/admin/announcements_controller_spec.rb => spec/controllers/admin/announcements_controller_spec.rb +55 -0
@@ 18,4 18,59 @@ describe Admin::AnnouncementsController do
      expect(response).to have_http_status(:success)
    end
  end

  describe 'GET #new' do
    it 'returns http success and renders new' do
      get :new

      expect(response).to have_http_status(:success)
      expect(response).to render_template(:new)
    end
  end

  describe 'GET #edit' do
    let(:announcement) { Fabricate(:announcement) }

    it 'returns http success and renders edit' do
      get :edit, params: { id: announcement.id }

      expect(response).to have_http_status(:success)
      expect(response).to render_template(:edit)
    end
  end

  describe 'POST #create' do
    it 'creates a new announcement and redirects' do
      expect do
        post :create, params: { announcement: { text: 'The announcement message.' } }
      end.to change(Announcement, :count).by(1)

      expect(response).to redirect_to(admin_announcements_path)
      expect(flash.notice).to match(I18n.t('admin.announcements.published_msg'))
    end
  end

  describe 'PUT #update' do
    let(:announcement) { Fabricate(:announcement, text: 'Original text') }

    it 'updates an announcement and redirects' do
      put :update, params: { id: announcement.id, announcement: { text: 'Updated text.' } }

      expect(response).to redirect_to(admin_announcements_path)
      expect(flash.notice).to match(I18n.t('admin.announcements.updated_msg'))
    end
  end

  describe 'DELETE #destroy' do
    let!(:announcement) { Fabricate(:announcement, text: 'Original text') }

    it 'destroys an announcement and redirects' do
      expect do
        delete :destroy, params: { id: announcement.id }
      end.to change(Announcement, :count).by(-1)

      expect(response).to redirect_to(admin_announcements_path)
      expect(flash.notice).to match(I18n.t('admin.announcements.destroyed_msg'))
    end
  end
end

D spec/controllers/api/v1/featured_tags_controller_spec.rb => spec/controllers/api/v1/featured_tags_controller_spec.rb +0 -23
@@ 1,23 0,0 @@
# frozen_string_literal: true

require 'rails_helper'

describe Api::V1::FeaturedTagsController do
  render_views

  let(:user)    { Fabricate(:user) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
  let(:account) { Fabricate(:account) }

  before do
    allow(controller).to receive(:doorkeeper_token) { token }
  end

  describe 'GET #index' do
    it 'returns http success' do
      get :index, params: { account_id: account.id, limit: 2 }

      expect(response).to have_http_status(200)
    end
  end
end

M spec/fabricators/featured_tag_fabricator.rb => spec/fabricators/featured_tag_fabricator.rb +1 -1
@@ 3,5 3,5 @@
Fabricator(:featured_tag) do
  account
  tag
  name 'Tag'
  name { sequence(:name) { |i| "Tag#{i}" } }
end

A spec/features/captcha_spec.rb => spec/features/captcha_spec.rb +35 -0
@@ 0,0 1,35 @@
# frozen_string_literal: true

require 'rails_helper'

describe 'email confirmation flow when captcha is enabled' do
  let(:user)        { Fabricate(:user, confirmed_at: nil, confirmation_token: 'foobar', created_by_application: client_app) }
  let(:client_app)  { nil }

  before do
    # rubocop:disable RSpec/AnyInstance -- easiest way to deal with that that I know of
    allow_any_instance_of(Auth::ConfirmationsController).to receive(:captcha_enabled?).and_return(true)
    allow_any_instance_of(Auth::ConfirmationsController).to receive(:check_captcha!).and_return(true)
    allow_any_instance_of(Auth::ConfirmationsController).to receive(:render_captcha).and_return(nil)
    # rubocop:enable RSpec/AnyInstance
  end

  context 'when the user signed up through an app' do
    let(:client_app) { Fabricate(:application) }

    it 'logs in' do
      visit "/auth/confirmation?confirmation_token=#{user.confirmation_token}&redirect_to_app=true"

      # It presents the user with a captcha form
      expect(page).to have_title(I18n.t('auth.captcha_confirmation.title'))

      # It does not confirm the user just yet
      expect(user.reload.confirmed?).to be false

      # It redirects to app and confirms user
      click_on I18n.t('challenge.confirm')
      expect(user.reload.confirmed?).to be true
      expect(page).to have_current_path(/\A#{client_app.confirmation_redirect_uri}/, url: true)
    end
  end
end

M spec/policies/status_policy_spec.rb => spec/policies/status_policy_spec.rb +93 -81
@@ 11,75 11,79 @@ RSpec.describe StatusPolicy, type: :model do
  let(:bob) { Fabricate(:account, username: 'bob') }
  let(:status) { Fabricate(:status, account: alice) }

  permissions :show?, :reblog? do
    it 'grants access when no viewer' do
      expect(subject).to permit(nil, status)
    end
  context 'with the permissions of show? and reblog?' do
    permissions :show?, :reblog? do
      it 'grants access when no viewer' do
        expect(subject).to permit(nil, status)
      end

    it 'denies access when viewer is blocked' do
      block = Fabricate(:block)
      status.visibility = :private
      status.account = block.target_account
      it 'denies access when viewer is blocked' do
        block = Fabricate(:block)
        status.visibility = :private
        status.account = block.target_account

      expect(subject).to_not permit(block.account, status)
        expect(subject).to_not permit(block.account, status)
      end
    end
  end

  permissions :show? do
    it 'grants access when direct and account is viewer' do
      status.visibility = :direct
  context 'with the permission of show?' do
    permissions :show? do
      it 'grants access when direct and account is viewer' do
        status.visibility = :direct

      expect(subject).to permit(status.account, status)
    end
        expect(subject).to permit(status.account, status)
      end

    it 'grants access when direct and viewer is mentioned' do
      status.visibility = :direct
      status.mentions = [Fabricate(:mention, account: alice)]
      it 'grants access when direct and viewer is mentioned' do
        status.visibility = :direct
        status.mentions = [Fabricate(:mention, account: alice)]

      expect(subject).to permit(alice, status)
    end
        expect(subject).to permit(alice, status)
      end

    it 'grants access when direct and non-owner viewer is mentioned and mentions are loaded' do
      status.visibility = :direct
      status.mentions = [Fabricate(:mention, account: bob)]
      status.mentions.load
      it 'grants access when direct and non-owner viewer is mentioned and mentions are loaded' do
        status.visibility = :direct
        status.mentions = [Fabricate(:mention, account: bob)]
        status.mentions.load

      expect(subject).to permit(bob, status)
    end
        expect(subject).to permit(bob, status)
      end

    it 'denies access when direct and viewer is not mentioned' do
      viewer = Fabricate(:account)
      status.visibility = :direct
      it 'denies access when direct and viewer is not mentioned' do
        viewer = Fabricate(:account)
        status.visibility = :direct

      expect(subject).to_not permit(viewer, status)
    end
        expect(subject).to_not permit(viewer, status)
      end

    it 'grants access when private and account is viewer' do
      status.visibility = :private
      it 'grants access when private and account is viewer' do
        status.visibility = :private

      expect(subject).to permit(status.account, status)
    end
        expect(subject).to permit(status.account, status)
      end

    it 'grants access when private and account is following viewer' do
      follow = Fabricate(:follow)
      status.visibility = :private
      status.account = follow.target_account
      it 'grants access when private and account is following viewer' do
        follow = Fabricate(:follow)
        status.visibility = :private
        status.account = follow.target_account

      expect(subject).to permit(follow.account, status)
    end
        expect(subject).to permit(follow.account, status)
      end

    it 'grants access when private and viewer is mentioned' do
      status.visibility = :private
      status.mentions = [Fabricate(:mention, account: alice)]
      it 'grants access when private and viewer is mentioned' do
        status.visibility = :private
        status.mentions = [Fabricate(:mention, account: alice)]

      expect(subject).to permit(alice, status)
    end
        expect(subject).to permit(alice, status)
      end

    it 'denies access when private and viewer is not mentioned or followed' do
      viewer = Fabricate(:account)
      status.visibility = :private
      it 'denies access when private and viewer is not mentioned or followed' do
        viewer = Fabricate(:account)
        status.visibility = :private

      expect(subject).to_not permit(viewer, status)
        expect(subject).to_not permit(viewer, status)
      end
    end

    it 'denies access when local-only and the viewer is not logged in' do


@@ 95,55 99,63 @@ RSpec.describe StatusPolicy, type: :model do
    end
  end

  permissions :reblog? do
    it 'denies access when private' do
      viewer = Fabricate(:account)
      status.visibility = :private
  context 'with the permission of reblog?' do
    permissions :reblog? do
      it 'denies access when private' do
        viewer = Fabricate(:account)
        status.visibility = :private

      expect(subject).to_not permit(viewer, status)
    end
        expect(subject).to_not permit(viewer, status)
      end

    it 'denies access when direct' do
      viewer = Fabricate(:account)
      status.visibility = :direct
      it 'denies access when direct' do
        viewer = Fabricate(:account)
        status.visibility = :direct

      expect(subject).to_not permit(viewer, status)
        expect(subject).to_not permit(viewer, status)
      end
    end
  end

  permissions :destroy?, :unreblog? do
    it 'grants access when account is deleter' do
      expect(subject).to permit(status.account, status)
    end
  context 'with the permissions of destroy? and unreblog?' do
    permissions :destroy?, :unreblog? do
      it 'grants access when account is deleter' do
        expect(subject).to permit(status.account, status)
      end

    it 'denies access when account is not deleter' do
      expect(subject).to_not permit(bob, status)
    end
      it 'denies access when account is not deleter' do
        expect(subject).to_not permit(bob, status)
      end

    it 'denies access when no deleter' do
      expect(subject).to_not permit(nil, status)
      it 'denies access when no deleter' do
        expect(subject).to_not permit(nil, status)
      end
    end
  end

  permissions :favourite? do
    it 'grants access when viewer is not blocked' do
      follow         = Fabricate(:follow)
      status.account = follow.target_account
  context 'with the permission of favourite?' do
    permissions :favourite? do
      it 'grants access when viewer is not blocked' do
        follow         = Fabricate(:follow)
        status.account = follow.target_account

      expect(subject).to permit(follow.account, status)
    end
        expect(subject).to permit(follow.account, status)
      end

    it 'denies when viewer is blocked' do
      block          = Fabricate(:block)
      status.account = block.target_account
      it 'denies when viewer is blocked' do
        block          = Fabricate(:block)
        status.account = block.target_account

      expect(subject).to_not permit(block.account, status)
        expect(subject).to_not permit(block.account, status)
      end
    end
  end

  permissions :update? do
    it 'grants access if owner' do
      expect(subject).to permit(status.account, status)
  context 'with the permission of update?' do
    permissions :update? do
      it 'grants access if owner' do
        expect(subject).to permit(status.account, status)
      end
    end
  end
end

M spec/presenters/status_relationships_presenter_spec.rb => spec/presenters/status_relationships_presenter_spec.rb +1 -1
@@ 15,7 15,7 @@ RSpec.describe StatusRelationshipsPresenter do
    let(:presenter)          { StatusRelationshipsPresenter.new(statuses, current_account_id, **options) }
    let(:current_account_id) { Fabricate(:account).id }
    let(:statuses)           { [Fabricate(:status)] }
    let(:status_ids)         { statuses.map(&:id) + statuses.map(&:reblog_of_id).compact }
    let(:status_ids)         { statuses.map(&:id) + statuses.filter_map(&:reblog_of_id) }
    let(:default_map)        { { 1 => true } }

    context 'when options are not set' do

A spec/requests/api/v1/featured_tags_spec.rb => spec/requests/api/v1/featured_tags_spec.rb +201 -0
@@ 0,0 1,201 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'FeaturedTags' do
  let(:user)    { Fabricate(:user) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:scopes)  { 'read:accounts write:accounts' }
  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      expect(response).to have_http_status(403)
    end
  end

  describe 'GET /api/v1/featured_tags' do
    context 'with wrong scope' do
      before do
        get '/api/v1/featured_tags', headers: headers
      end

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    end

    context 'when Authorization header is missing' do
      it 'returns http unauthorized' do
        get '/api/v1/featured_tags'

        expect(response).to have_http_status(401)
      end
    end

    it 'returns http success' do
      get '/api/v1/featured_tags', headers: headers

      expect(response).to have_http_status(200)
    end

    context 'when the requesting user has no featured tag' do
      before { Fabricate.times(3, :featured_tag) }

      it 'returns an empty body' do
        get '/api/v1/featured_tags', headers: headers

        body = body_as_json

        expect(body).to be_empty
      end
    end

    context 'when the requesting user has featured tags' do
      let!(:user_featured_tags) { Fabricate.times(5, :featured_tag, account: user.account) }

      it 'returns only the featured tags belonging to the requesting user' do
        get '/api/v1/featured_tags', headers: headers

        body = body_as_json
        expected_ids = user_featured_tags.pluck(:id).map(&:to_s)

        expect(body.pluck(:id)).to match_array(expected_ids)
      end
    end
  end

  describe 'POST /api/v1/featured_tags' do
    let(:params) { { name: 'tag' } }

    it 'returns http success' do
      post '/api/v1/featured_tags', headers: headers, params: params

      expect(response).to have_http_status(200)
    end

    it 'returns the correct tag name' do
      post '/api/v1/featured_tags', headers: headers, params: params

      body = body_as_json

      expect(body[:name]).to eq(params[:name])
    end

    it 'creates a new featured tag for the requesting user' do
      post '/api/v1/featured_tags', headers: headers, params: params

      featured_tag = FeaturedTag.find_by(name: params[:name], account: user.account)

      expect(featured_tag).to be_present
    end

    context 'with wrong scope' do
      before do
        post '/api/v1/featured_tags', headers: headers, params: params
      end

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    end

    context 'when Authorization header is missing' do
      it 'returns http unauthorized' do
        post '/api/v1/featured_tags', params: params

        expect(response).to have_http_status(401)
      end
    end

    context 'when required param "name" is not provided' do
      it 'returns http bad request' do
        post '/api/v1/featured_tags', headers: headers

        expect(response).to have_http_status(400)
      end
    end

    context 'when provided tag name is invalid' do
      let(:params) { { name: 'asj&*!' } }

      it 'returns http unprocessable entity' do
        post '/api/v1/featured_tags', headers: headers, params: params

        expect(response).to have_http_status(422)
      end
    end

    context 'when tag name is already taken' do
      before do
        FeaturedTag.create(name: params[:name], account: user.account)
      end

      it 'returns http unprocessable entity' do
        post '/api/v1/featured_tags', headers: headers, params: params

        expect(response).to have_http_status(422)
      end
    end
  end

  describe 'DELETE /api/v1/featured_tags' do
    let!(:featured_tag) { FeaturedTag.create(name: 'tag', account: user.account) }
    let(:id) { featured_tag.id }

    it 'returns http success' do
      delete "/api/v1/featured_tags/#{id}", headers: headers

      expect(response).to have_http_status(200)
    end

    it 'returns an empty body' do
      delete "/api/v1/featured_tags/#{id}", headers: headers

      body = body_as_json

      expect(body).to be_empty
    end

    it 'deletes the featured tag' do
      delete "/api/v1/featured_tags/#{id}", headers: headers

      featured_tag = FeaturedTag.find_by(id: id)

      expect(featured_tag).to be_nil
    end

    context 'with wrong scope' do
      before do
        delete "/api/v1/featured_tags/#{id}", headers: headers
      end

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    end

    context 'when Authorization header is missing' do
      it 'returns http unauthorized' do
        delete "/api/v1/featured_tags/#{id}"

        expect(response).to have_http_status(401)
      end
    end

    context 'when featured tag with given id does not exist' do
      it 'returns http not found' do
        delete '/api/v1/featured_tags/0', headers: headers

        expect(response).to have_http_status(404)
      end
    end

    context 'when deleting a featured tag of another user' do
      let!(:other_user_featured_tag) { Fabricate(:featured_tag) }
      let(:id) { other_user_featured_tag.id }

      it 'returns http not found' do
        delete "/api/v1/featured_tags/#{id}", headers: headers

        expect(response).to have_http_status(404)
      end
    end
  end
end

M yarn.lock => yarn.lock +76 -71
@@ 2449,15 2449,15 @@
  dependencies:
    "@types/yargs-parser" "*"

"@typescript-eslint/eslint-plugin@^5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz#a350faef1baa1e961698240f922d8de1761a9e2b"
  integrity sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==
"@typescript-eslint/eslint-plugin@^5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.7.tgz#e470af414f05ecfdc05a23e9ce6ec8f91db56fe2"
  integrity sha512-BL+jYxUFIbuYwy+4fF86k5vdT9lT0CNJ6HtwrIvGh0PhH8s0yy5rjaKH2fDCrz5ITHy07WCzVGNvAmjJh4IJFA==
  dependencies:
    "@eslint-community/regexpp" "^4.4.0"
    "@typescript-eslint/scope-manager" "5.59.6"
    "@typescript-eslint/type-utils" "5.59.6"
    "@typescript-eslint/utils" "5.59.6"
    "@typescript-eslint/scope-manager" "5.59.7"
    "@typescript-eslint/type-utils" "5.59.7"
    "@typescript-eslint/utils" "5.59.7"
    debug "^4.3.4"
    grapheme-splitter "^1.0.4"
    ignore "^5.2.0"


@@ 2465,31 2465,31 @@
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/parser@^5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.6.tgz#bd36f71f5a529f828e20b627078d3ed6738dbb40"
  integrity sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==
"@typescript-eslint/parser@^5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.7.tgz#02682554d7c1028b89aa44a48bf598db33048caa"
  integrity sha512-VhpsIEuq/8i5SF+mPg9jSdIwgMBBp0z9XqjiEay+81PYLJuroN+ET1hM5IhkiYMJd9MkTz8iJLt7aaGAgzWUbQ==
  dependencies:
    "@typescript-eslint/scope-manager" "5.59.6"
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/typescript-estree" "5.59.6"
    "@typescript-eslint/scope-manager" "5.59.7"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/typescript-estree" "5.59.7"
    debug "^4.3.4"

"@typescript-eslint/scope-manager@5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz#d43a3687aa4433868527cfe797eb267c6be35f19"
  integrity sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==
"@typescript-eslint/scope-manager@5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.7.tgz#0243f41f9066f3339d2f06d7f72d6c16a16769e2"
  integrity sha512-FL6hkYWK9zBGdxT2wWEd2W8ocXMu3K94i3gvMrjXpx+koFYdYV7KprKfirpgY34vTGzEPPuKoERpP8kD5h7vZQ==
  dependencies:
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/visitor-keys" "5.59.6"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/visitor-keys" "5.59.7"

"@typescript-eslint/type-utils@5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz#37c51d2ae36127d8b81f32a0a4d2efae19277c48"
  integrity sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==
"@typescript-eslint/type-utils@5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.7.tgz#89c97291371b59eb18a68039857c829776f1426d"
  integrity sha512-ozuz/GILuYG7osdY5O5yg0QxXUAEoI4Go3Do5xeu+ERH9PorHBPSdvD3Tjp2NN2bNLh1NJQSsQu2TPu/Ly+HaQ==
  dependencies:
    "@typescript-eslint/typescript-estree" "5.59.6"
    "@typescript-eslint/utils" "5.59.6"
    "@typescript-eslint/typescript-estree" "5.59.7"
    "@typescript-eslint/utils" "5.59.7"
    debug "^4.3.4"
    tsutils "^3.21.0"



@@ 2498,10 2498,10 @@
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.0.tgz#3fcdac7dbf923ec5251545acdd9f1d42d7c4fe32"
  integrity sha512-yR2h1NotF23xFFYKHZs17QJnB51J/s+ud4PYU4MqdZbzeNxpgUr05+dNeCN/bb6raslHvGdd6BFCkVhpPk/ZeA==

"@typescript-eslint/types@5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.6.tgz#5a6557a772af044afe890d77c6a07e8c23c2460b"
  integrity sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==
"@typescript-eslint/types@5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.7.tgz#6f4857203fceee91d0034ccc30512d2939000742"
  integrity sha512-UnVS2MRRg6p7xOSATscWkKjlf/NDKuqo5TdbWck6rIRZbmKpVNTLALzNvcjIfHBE7736kZOFc/4Z3VcZwuOM/A==

"@typescript-eslint/typescript-estree@5.59.0":
  version "5.59.0"


@@ 2516,30 2516,30 @@
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/typescript-estree@5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz#2fb80522687bd3825504925ea7e1b8de7bb6251b"
  integrity sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==
"@typescript-eslint/typescript-estree@5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.7.tgz#b887acbd4b58e654829c94860dbff4ac55c5cff8"
  integrity sha512-4A1NtZ1I3wMN2UGDkU9HMBL+TIQfbrh4uS0WDMMpf3xMRursDbqEf1ahh6vAAe3mObt8k3ZATnezwG4pdtWuUQ==
  dependencies:
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/visitor-keys" "5.59.6"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/visitor-keys" "5.59.7"
    debug "^4.3.4"
    globby "^11.1.0"
    is-glob "^4.0.3"
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/utils@5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.6.tgz#82960fe23788113fc3b1f9d4663d6773b7907839"
  integrity sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==
"@typescript-eslint/utils@5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.7.tgz#7adf068b136deae54abd9a66ba5a8780d2d0f898"
  integrity sha512-yCX9WpdQKaLufz5luG4aJbOpdXf/fjwGMcLFXZVPUz3QqLirG5QcwwnIHNf8cjLjxK4qtzTO8udUtMQSAToQnQ==
  dependencies:
    "@eslint-community/eslint-utils" "^4.2.0"
    "@types/json-schema" "^7.0.9"
    "@types/semver" "^7.3.12"
    "@typescript-eslint/scope-manager" "5.59.6"
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/typescript-estree" "5.59.6"
    "@typescript-eslint/scope-manager" "5.59.7"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/typescript-estree" "5.59.7"
    eslint-scope "^5.1.1"
    semver "^7.3.7"



@@ 2551,12 2551,12 @@
    "@typescript-eslint/types" "5.59.0"
    eslint-visitor-keys "^3.3.0"

"@typescript-eslint/visitor-keys@5.59.6":
  version "5.59.6"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz#673fccabf28943847d0c8e9e8d008e3ada7be6bb"
  integrity sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==
"@typescript-eslint/visitor-keys@5.59.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.7.tgz#09c36eaf268086b4fbb5eb9dc5199391b6485fc5"
  integrity sha512-tyN+X2jvMslUszIiYbF0ZleP+RqQsFVpGrKI6e0Eet1w8WmhsAtmzaqm8oM8WJQ1ysLwhnsK/4hYHJjOgJVfQQ==
  dependencies:
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/types" "5.59.7"
    eslint-visitor-keys "^3.3.0"

"@webassemblyjs/ast@1.9.0":


@@ 5891,15 5891,15 @@ glob-parent@^6.0.2:
  dependencies:
    is-glob "^4.0.3"

glob@^10.0.0, glob@^10.2.2:
  version "10.2.2"
  resolved "https://registry.yarnpkg.com/glob/-/glob-10.2.2.tgz#ce2468727de7e035e8ecf684669dc74d0526ab75"
  integrity sha512-Xsa0BcxIC6th9UwNjZkhrMtNo/MnyRL8jGCP+uEwhA5oFOCY1f2s1/oNKY47xQ0Bg5nkjsfAEIej1VeH62bDDQ==
glob@^10.2.5, glob@^10.2.6:
  version "10.2.6"
  resolved "https://registry.yarnpkg.com/glob/-/glob-10.2.6.tgz#1e27edbb3bbac055cb97113e27a066c100a4e5e1"
  integrity sha512-U/rnDpXJGF414QQQZv5uVsabTVxMSwzS5CH0p3DRCIV6ownl4f7PzGnkGmvlum2wB+9RlJWJZ6ACU1INnBqiPA==
  dependencies:
    foreground-child "^3.1.0"
    jackspeak "^2.0.3"
    minimatch "^9.0.0"
    minipass "^5.0.0"
    minimatch "^9.0.1"
    minipass "^5.0.0 || ^6.0.2"
    path-scurry "^1.7.0"

glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:


@@ 8135,10 8135,10 @@ minimatch@^5.0.1:
  dependencies:
    brace-expansion "^2.0.1"

minimatch@^9.0.0:
  version "9.0.0"
  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.0.tgz#bfc8e88a1c40ffd40c172ddac3decb8451503b56"
  integrity sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==
minimatch@^9.0.1:
  version "9.0.1"
  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
  integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
  dependencies:
    brace-expansion "^2.0.1"



@@ 8189,6 8189,11 @@ minipass@^5.0.0:
  resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
  integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==

"minipass@^5.0.0 || ^6.0.2":
  version "6.0.2"
  resolved "https://registry.yarnpkg.com/minipass/-/minipass-6.0.2.tgz#542844b6c4ce95b202c0995b0a471f1229de4c81"
  integrity sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==

minizlib@^2.1.1:
  version "2.1.2"
  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"


@@ 10162,12 10167,12 @@ rimraf@^3.0.2:
  dependencies:
    glob "^7.1.3"

rimraf@^5.0.0:
  version "5.0.0"
  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.0.tgz#5bda14e410d7e4dd522154891395802ce032c2cb"
  integrity sha512-Jf9llaP+RvaEVS5nPShYFhtXIrb3LRKP281ib3So0KkeZKo2wIKyq0Re7TOSwanasA423PSr6CCIL4bP6T040g==
rimraf@^5.0.1:
  version "5.0.1"
  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.1.tgz#0881323ab94ad45fec7c0221f27ea1a142f3f0d0"
  integrity sha512-OfFZdwtd3lZ+XZzYP/6gTACubwFcHdLRqS9UX3UwpU2dnGQYkPFISRwvM3w9IiB2w7bW5qGo/uAwE4SmXXSKvg==
  dependencies:
    glob "^10.0.0"
    glob "^10.2.5"

ripemd160@^2.0.0, ripemd160@^2.0.1:
  version "2.0.2"


@@ 11045,10 11050,10 @@ stylelint-scss@^4.6.0:
    postcss-selector-parser "^6.0.11"
    postcss-value-parser "^4.2.0"

stylelint@^15.6.1:
  version "15.6.1"
  resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-15.6.1.tgz#e4cd33a3af88587b99a5d1328aedd8c298b6dc81"
  integrity sha512-d8icFBlVl93Elf3Z5ABQNOCe4nx69is3D/NZhDLAie1eyYnpxfeKe7pCfqzT5W4F8vxHCLSDfV8nKNJzogvV2Q==
stylelint@^15.6.2:
  version "15.6.2"
  resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-15.6.2.tgz#06d9005b62a83b72887eed623520e9b472af8c15"
  integrity sha512-fjQWwcdUye4DU+0oIxNGwawIPC5DvG5kdObY5Sg4rc87untze3gC/5g/ikePqVjrAsBUZjwMN+pZsAYbDO6ArQ==
  dependencies:
    "@csstools/css-parser-algorithms" "^2.1.1"
    "@csstools/css-tokenizer" "^2.1.1"


@@ 11968,10 11973,10 @@ webpack-log@^2.0.0:
    ansi-colors "^3.0.0"
    uuid "^3.3.2"

webpack-merge@^5.8.0:
  version "5.8.0"
  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61"
  integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==
webpack-merge@^5.9.0:
  version "5.9.0"
  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.9.0.tgz#dc160a1c4cf512ceca515cc231669e9ddb133826"
  integrity sha512-6NbRQw4+Sy50vYNTw7EyOn41OZItPiXB8GNv3INSoe3PSFaHJEz3SHTrYVaRm2LilNGnFUzh0FAwqPEmU/CwDg==
  dependencies:
    clone-deep "^4.0.1"
    wildcard "^2.0.0"