~cytrogen/masto-fe

d8b0a732aaad9ed0c3e09ad26c51b3829aa237de — Claire 2 years ago 9e66c07 + 1483a3d
Merge commit '1483a3ddfe74e4fb81d87447a1781943eab86c60' into glitch-soc/merge-upstream

Conflicts:
- `config/initializers/simple_form.rb`:
  Upstream added a new simple_form component, where we had an extra one.
  Kept both components.
68 files changed, 837 insertions(+), 159 deletions(-)

M .eslintrc.js
M .haml-lint.yml
M .rubocop.yml
M Gemfile
M Gemfile.lock
M app/controllers/api/v1/lists_controller.rb
M app/controllers/backups_controller.rb
M app/javascript/mastodon/actions/importer/normalizer.js
M app/javascript/mastodon/actions/lists.js
M app/javascript/mastodon/features/explore/index.jsx
M app/javascript/mastodon/features/list_timeline/index.jsx
M app/javascript/mastodon/features/onboarding/index.jsx
M app/javascript/mastodon/features/status/components/detailed_status.jsx
M app/javascript/mastodon/locales/en.json
M app/javascript/mastodon/locales/intl_provider.tsx
M app/javascript/mastodon/reducers/list_editor.js
M app/javascript/styles/mastodon/accounts.scss
M app/javascript/styles/mastodon/forms.scss
M app/lib/admin/metrics/dimension/instance_accounts_dimension.rb
M app/lib/admin/metrics/dimension/instance_languages_dimension.rb
M app/lib/admin/metrics/dimension/languages_dimension.rb
A app/lib/admin/metrics/dimension/query_helper.rb
M app/lib/admin/metrics/dimension/servers_dimension.rb
M app/lib/admin/metrics/dimension/sources_dimension.rb
M app/lib/admin/metrics/dimension/tag_languages_dimension.rb
M app/lib/admin/metrics/dimension/tag_servers_dimension.rb
M app/lib/feed_manager.rb
M app/models/list.rb
M app/serializers/rest/list_serializer.rb
M app/views/admin/email_domain_blocks/_email_domain_block.html.haml
M app/views/admin/export_domain_blocks/_domain_block.html.haml
M app/views/admin/instances/_instance.html.haml
M app/views/admin/instances/show.html.haml
M app/views/admin/ip_blocks/_ip_block.html.haml
M app/views/admin/roles/_role.html.haml
M app/views/admin/settings/content_retention/show.html.haml
M app/views/admin/trends/links/_preview_card.html.haml
M app/views/admin/trends/statuses/_status.html.haml
M app/views/admin/trends/tags/_tag.html.haml
M app/views/admin/webhooks/_webhook.html.haml
M app/views/admin_mailer/_new_trending_links.text.erb
M app/views/admin_mailer/_new_trending_statuses.text.erb
M app/views/admin_mailer/_new_trending_tags.text.erb
M app/views/application/_card.html.haml
M app/views/auth/registrations/rules.html.haml
M app/views/oauth/authorized_applications/index.html.haml
M config/initializers/simple_form.rb
D config/initializers/statsd.rb
M config/locales/en.yml
M config/locales/simple_form.en.yml
D config/webpack/translationRunner.js
A db/migrate/20230605085710_add_exclusive_to_lists.rb
M db/schema.rb
A lib/linter/haml_middle_dot.rb
A lib/linter/rubocop_middle_dot.rb
M package.json
A spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb
A spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb
A spec/lib/admin/metrics/dimension/languages_dimension_spec.rb
A spec/lib/admin/metrics/dimension/servers_dimension_spec.rb
A spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb
A spec/lib/admin/metrics/dimension/sources_dimension_spec.rb
A spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb
A spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb
A spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb
M spec/lib/feed_manager_spec.rb
M spec/lib/mastodon/cli/accounts_spec.rb
M spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
M .eslintrc.js => .eslintrc.js +9 -0
@@ 81,6 81,15 @@ module.exports = {
      { property: 'substring', message: 'Use .slice instead of .substring.' },
      { property: 'substr', message: 'Use .slice instead of .substr.' },
    ],
    'no-restricted-syntax': [
      'error',
      {
        // eslint-disable-next-line no-restricted-syntax
        selector: 'Literal[value=/•/], JSXText[value=/•/]',
        // eslint-disable-next-line no-restricted-syntax
        message: "Use '·' (middle dot) instead of '•' (bullet)",
      },
    ],
    'no-self-assign': 'off',
    'no-unused-expressions': 'error',
    'no-unused-vars': 'off',

M .haml-lint.yml => .haml-lint.yml +5 -0
@@ 4,6 4,11 @@ exclude:
  - 'vendor/**/*'
  - lib/templates/haml/scaffold/_form.html.haml

require:
  - ./lib/linter/haml_middle_dot.rb

linters:
  AltText:
    enabled: true
  MiddleDot:
    enabled: true

M .rubocop.yml => .rubocop.yml +4 -0
@@ 11,6 11,7 @@ require:
  - rubocop-rspec
  - rubocop-performance
  - rubocop-capybara
  - ./lib/linter/rubocop_middle_dot

AllCops:
  TargetRubyVersion: 3.0 # Set to minimum supported version of CI


@@ 205,3 206,6 @@ Style/TrailingCommaInArrayLiteral:
# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral
Style/TrailingCommaInHashLiteral:
  EnforcedStyleForMultiline: 'comma'

Style/MiddleDot:
  Enabled: true

M Gemfile => Gemfile +1 -2
@@ 20,7 20,7 @@ gem 'dotenv-rails', '~> 2.8'
gem 'aws-sdk-s3', '~> 1.123', 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'
gem 'kt-paperclip', '~> 7.2'
gem 'blurhash', '~> 0.1'

gem 'active_model_serializers', '~> 0.10'


@@ 60,7 60,6 @@ gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.15'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14'
gem 'parslet'

M Gemfile.lock => Gemfile.lock +7 -20
@@ 8,18 8,6 @@ GIT
      jwt (~> 2.0)

GIT
  remote: https://github.com/kreeti/kt-paperclip.git
  revision: 11abf222dc31bff71160a1d138b445214f434b2b
  ref: 11abf222dc31bff71160a1d138b445214f434b2b
  specs:
    kt-paperclip (7.1.1)
      activemodel (>= 4.2.0)
      activesupport (>= 4.2.0)
      marcel (~> 1.0.1)
      mime-types
      terrapin (~> 0.6.0)

GIT
  remote: https://github.com/mastodon/rails-settings-cached.git
  revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab
  branch: v0.6.6-aliases-true


@@ 380,6 368,12 @@ GEM
      activerecord
      kaminari-core (= 1.2.2)
    kaminari-core (1.2.2)
    kt-paperclip (7.2.0)
      activemodel (>= 4.2.0)
      activesupport (>= 4.2.0)
      marcel (~> 1.0.1)
      mime-types
      terrapin (~> 0.6.0)
    launchy (2.5.2)
      addressable (~> 2.8)
    letter_opener (1.8.1)


@@ 442,11 436,6 @@ GEM
    nokogiri (1.15.2)
      mini_portile2 (~> 2.8.2)
      racc (~> 1.4)
    nsa (0.2.8)
      activesupport (>= 4.2, < 7)
      concurrent-ruby (~> 1.0, >= 1.0.2)
      sidekiq (>= 3.5)
      statsd-ruby (~> 1.4, >= 1.4.0)
    oj (3.14.3)
    omniauth (1.9.2)
      hashie (>= 3.4.6)


@@ 682,7 671,6 @@ GEM
      net-scp (>= 1.1.2)
      net-ssh (>= 2.8.0)
    stackprof (0.2.25)
    statsd-ruby (1.5.0)
    stoplight (3.0.1)
      redlock (~> 1.0)
    strong_migrations (0.8.0)


@@ 819,7 807,7 @@ DEPENDENCIES
  json-ld-preloaded (~> 3.2)
  json-schema (~> 4.0)
  kaminari (~> 1.2)
  kt-paperclip (~> 7.1)!
  kt-paperclip (~> 7.2)
  letter_opener (~> 1.8)
  letter_opener_web (~> 2.0)
  link_header (~> 0.0)


@@ 831,7 819,6 @@ DEPENDENCIES
  net-http (~> 0.3.2)
  net-ldap (~> 0.18)
  nokogiri (~> 1.15)
  nsa (~> 0.2)
  oj (~> 3.14)
  omniauth (~> 1.9)
  omniauth-cas (~> 2.0)

M app/controllers/api/v1/lists_controller.rb => app/controllers/api/v1/lists_controller.rb +1 -1
@@ 42,6 42,6 @@ class Api::V1::ListsController < Api::BaseController
  end

  def list_params
    params.permit(:title, :replies_policy)
    params.permit(:title, :replies_policy, :exclusive)
  end
end

M app/controllers/backups_controller.rb => app/controllers/backups_controller.rb +4 -4
@@ 11,15 11,15 @@ class BackupsController < ApplicationController
  def download
    case Paperclip::Attachment.default_options[:storage]
    when :s3
      redirect_to @backup.dump.expiring_url(10)
      redirect_to @backup.dump.expiring_url(10), allow_other_host: true
    when :fog
      if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
        redirect_to @backup.dump.expiring_url(Time.now.utc + 10)
        redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
      else
        redirect_to full_asset_url(@backup.dump.url)
        redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
      end
    when :filesystem
      redirect_to full_asset_url(@backup.dump.url)
      redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
    end
  end


M app/javascript/mastodon/actions/importer/normalizer.js => app/javascript/mastodon/actions/importer/normalizer.js +1 -1
@@ 138,7 138,7 @@ export function normalizePollOptionTranslation(translation, poll) {

export function normalizeAnnouncement(announcement) {
  const normalAnnouncement = { ...announcement };
  const emojiMap = makeEmojiMap.emojis(normalAnnouncement);
  const emojiMap = makeEmojiMap(normalAnnouncement.emojis);

  normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);


M app/javascript/mastodon/actions/lists.js => app/javascript/mastodon/actions/lists.js +2 -2
@@ 151,10 151,10 @@ export const createListFail = error => ({
  error,
});

export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => {
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
  dispatch(updateListRequest(id));

  api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => {
  api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
    dispatch(updateListSuccess(data));

    if (shouldReset) {

M app/javascript/mastodon/features/explore/index.jsx => app/javascript/mastodon/features/explore/index.jsx +1 -1
@@ 67,7 67,7 @@ class Explore extends PureComponent {
          <Search />
        </div>

        <div className='scrollable scrollable--flex'>
        <div className='scrollable scrollable--flex' data-nosnippet>
          {isSearching ? (
            <SearchResults />
          ) : (

M app/javascript/mastodon/features/list_timeline/index.jsx => app/javascript/mastodon/features/list_timeline/index.jsx +17 -1
@@ 8,6 8,8 @@ import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';

import Toggle from 'react-toggle';

import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchList, deleteList, updateList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';


@@ 145,7 147,13 @@ class ListTimeline extends PureComponent {
  handleRepliesPolicyChange = ({ target }) => {
    const { dispatch } = this.props;
    const { id } = this.props.params;
    dispatch(updateList(id, undefined, false, target.value));
    dispatch(updateList(id, undefined, false, undefined, target.value));
  };

  onExclusiveToggle = ({ target }) => {
    const { dispatch } = this.props;
    const { id } = this.props.params;
    dispatch(updateList(id, undefined, false, target.checked, undefined));
  };

  render () {


@@ 154,6 162,7 @@ class ListTimeline extends PureComponent {
    const pinned = !!columnId;
    const title  = list ? list.get('title') : id;
    const replies_policy = list ? list.get('replies_policy') : undefined;
    const isExclusive = list ? list.get('exclusive') : undefined;

    if (typeof list === 'undefined') {
      return (


@@ 191,6 200,13 @@ class ListTimeline extends PureComponent {
            </button>
          </div>

          <div className='setting-toggle'>
            <Toggle id={`list-${id}-exclusive`} defaultChecked={isExclusive} onChange={this.onExclusiveToggle} />
            <label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
              <FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
            </label>
          </div>

          { replies_policy !== undefined && (
            <div role='group' aria-labelledby={`list-${id}-replies-policy`}>
              <span id={`list-${id}-replies-policy`} className='column-settings__section'>

M app/javascript/mastodon/features/onboarding/index.jsx => app/javascript/mastodon/features/onboarding/index.jsx +1 -1
@@ 121,7 121,7 @@ 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='Follow {count, plural, one {one person} other {# people}}' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own feed. Let's fill it with interesting people." />} />
            <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.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>

M app/javascript/mastodon/features/status/components/detailed_status.jsx => app/javascript/mastodon/features/status/components/detailed_status.jsx +3 -3
@@ 217,7 217,7 @@ class DetailedStatus extends ImmutablePureComponent {
    } else if (this.context.router) {
      reblogLink = (
        <>
           ·
          {' · '}
          <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
            <Icon id={reblogIcon} />
            <span className='detailed-status__reblogs'>


@@ 229,7 229,7 @@ class DetailedStatus extends ImmutablePureComponent {
    } else {
      reblogLink = (
        <>
           ·
          {' · '}
          <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
            <Icon id={reblogIcon} />
            <span className='detailed-status__reblogs'>


@@ 263,7 263,7 @@ class DetailedStatus extends ImmutablePureComponent {
    if (status.get('edited_at')) {
      edited = (
        <>
           ·
          {' · '}
          <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
        </>
      );

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +3 -2
@@ 356,6 356,7 @@
  "lists.delete": "Delete list",
  "lists.edit": "Edit list",
  "lists.edit.submit": "Change title",
  "lists.exclusive": "Hide these posts from home",
  "lists.new.create": "Add list",
  "lists.new.title_placeholder": "New list title",
  "lists.replies_policy.followed": "Any followed user",


@@ 460,8 461,8 @@
  "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.title": "You've made it!",
  "onboarding.steps.follow_people.body": "You curate your own feed. Lets fill it with interesting people.",
  "onboarding.steps.follow_people.title": "Follow {count, plural, one {one person} other {# people}}",
  "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.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.",

M app/javascript/mastodon/locales/intl_provider.tsx => app/javascript/mastodon/locales/intl_provider.tsx +1 -0
@@ 48,6 48,7 @@ export const IntlProvider: React.FC<
      locale={locale}
      messages={messages}
      onError={onProviderError}
      textComponent='span'
      {...props}
    >
      {children}

M app/javascript/mastodon/reducers/list_editor.js => app/javascript/mastodon/reducers/list_editor.js +2 -0
@@ 25,6 25,7 @@ const initialState = ImmutableMap({
  isSubmitting: false,
  isChanged: false,
  title: '',
  isExclusive: false,

  accounts: ImmutableMap({
    items: ImmutableList(),


@@ 46,6 47,7 @@ export default function listEditorReducer(state = initialState, action) {
    return state.withMutations(map => {
      map.set('listId', action.list.get('id'));
      map.set('title', action.list.get('title'));
      map.set('isExclusive', action.list.get('is_exclusive'));
      map.set('isSubmitting', false);
    });
  case LIST_EDITOR_TITLE_CHANGE:

M app/javascript/styles/mastodon/accounts.scss => app/javascript/styles/mastodon/accounts.scss +2 -12
@@ 3,11 3,8 @@
    display: block;
    text-decoration: none;
    color: inherit;
    box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);

    @media screen and (max-width: $no-gap-breakpoint) {
      box-shadow: none;
    }
    overflow: hidden;
    border-radius: 4px;

    &:hover,
    &:active,


@@ 22,7 19,6 @@
    height: 130px;
    position: relative;
    background: darken($ui-base-color, 12%);
    border-radius: 4px 4px 0 0;

    img {
      display: block;


@@ 30,7 26,6 @@
      height: 100%;
      margin: 0;
      object-fit: cover;
      border-radius: 4px 4px 0 0;
    }

    @media screen and (width <= 600px) {


@@ 45,11 40,6 @@
    justify-content: flex-start;
    align-items: center;
    background: lighten($ui-base-color, 4%);
    border-radius: 0 0 4px 4px;

    @media screen and (max-width: $no-gap-breakpoint) {
      border-radius: 0;
    }

    .avatar {
      flex: 0 0 auto;

M app/javascript/styles/mastodon/forms.scss => app/javascript/styles/mastodon/forms.scss +4 -0
@@ 137,6 137,10 @@ code {
    color: $secondary-text-color;
    margin-bottom: 30px;

    &.invited-by {
      margin-bottom: 15px;
    }

    a {
      color: $highlight-text-color;
    }

M app/lib/admin/metrics/dimension/instance_accounts_dimension.rb => app/lib/admin/metrics/dimension/instance_accounts_dimension.rb +12 -7
@@ 1,6 1,7 @@
# frozen_string_literal: true

class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dimension::BaseDimension
  include Admin::Metrics::Dimension::QueryHelper
  include LanguagesHelper

  def self.with_params?


@@ 14,19 15,23 @@ class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dim
  protected

  def perform_query
    sql = <<-SQL.squish
    dimension_data_rows.map { |row| { key: row['username'], human_key: row['username'], value: row['value'].to_s } }
  end

  def sql_array
    [sql_query_string, { domain: params[:domain], limit: @limit }]
  end

  def sql_query_string
    <<~SQL.squish
      SELECT accounts.username, count(follows.*) AS value
      FROM accounts
      LEFT JOIN follows ON follows.target_account_id = accounts.id
      WHERE accounts.domain = $1
      WHERE accounts.domain = :domain
      GROUP BY accounts.id, follows.target_account_id
      ORDER BY value DESC
      LIMIT $2
      LIMIT :limit
    SQL

    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, @limit]])

    rows.map { |row| { key: row['username'], human_key: row['username'], value: row['value'].to_s } }
  end

  def params

M app/lib/admin/metrics/dimension/instance_languages_dimension.rb => app/lib/admin/metrics/dimension/instance_languages_dimension.rb +19 -6
@@ 1,6 1,7 @@
# frozen_string_literal: true

class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
  include Admin::Metrics::Dimension::QueryHelper
  include LanguagesHelper

  def self.with_params?


@@ 14,21 15,33 @@ class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Di
  protected

  def perform_query
    sql = <<-SQL.squish
    dimension_data_rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
  end

  def sql_array
    [sql_query_string, { domain: params[:domain], earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
  end

  def sql_query_string
    <<~SQL.squish
      SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
      FROM statuses
      INNER JOIN accounts ON accounts.id = statuses.account_id
      WHERE accounts.domain = $1
        AND statuses.id BETWEEN $2 AND $3
      WHERE accounts.domain = :domain
        AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
        AND statuses.reblog_of_id IS NULL
      GROUP BY COALESCE(statuses.language, 'und')
      ORDER BY count(*) DESC
      LIMIT $4
      LIMIT :limit
    SQL
  end

    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
  def earliest_status_id
    Mastodon::Snowflake.id_at(@start_at, with_random: false)
  end

    rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
  def latest_status_id
    Mastodon::Snowflake.id_at(@end_at, with_random: false)
  end

  def params

M app/lib/admin/metrics/dimension/languages_dimension.rb => app/lib/admin/metrics/dimension/languages_dimension.rb +12 -7
@@ 1,6 1,7 @@
# frozen_string_literal: true

class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension
  include Admin::Metrics::Dimension::QueryHelper
  include LanguagesHelper

  def key


@@ 10,18 11,22 @@ class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension:
  protected

  def perform_query
    sql = <<-SQL.squish
    dimension_data_rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } }
  end

  def sql_array
    [sql_query_string, { start_at: @start_at, end_at: @end_at, limit: @limit }]
  end

  def sql_query_string
    <<~SQL.squish
      SELECT locale, count(*) AS value
      FROM users
      WHERE current_sign_in_at BETWEEN $1 AND $2
      WHERE current_sign_in_at BETWEEN :start_at AND :end_at
        AND locale IS NOT NULL
      GROUP BY locale
      ORDER BY count(*) DESC
      LIMIT $3
      LIMIT :limit
    SQL

    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])

    rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } }
  end
end

A app/lib/admin/metrics/dimension/query_helper.rb => app/lib/admin/metrics/dimension/query_helper.rb +13 -0
@@ 0,0 1,13 @@
# frozen_string_literal: true

module Admin::Metrics::Dimension::QueryHelper
  protected

  def dimension_data_rows
    ActiveRecord::Base.connection.select_all(sanitized_sql_string)
  end

  def sanitized_sql_string
    ActiveRecord::Base.sanitize_sql_array(sql_array)
  end
end

M app/lib/admin/metrics/dimension/servers_dimension.rb => app/lib/admin/metrics/dimension/servers_dimension.rb +19 -5
@@ 1,6 1,8 @@
# frozen_string_literal: true

class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension
  include Admin::Metrics::Dimension::QueryHelper

  def key
    'servers'
  end


@@ 8,18 10,30 @@ class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::B
  protected

  def perform_query
    sql = <<-SQL.squish
    dimension_data_rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
  end

  def sql_array
    [sql_query_string, { earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
  end

  def sql_query_string
    <<~SQL.squish
      SELECT accounts.domain, count(*) AS value
      FROM statuses
      INNER JOIN accounts ON accounts.id = statuses.account_id
      WHERE statuses.id BETWEEN $1 AND $2
      WHERE statuses.id BETWEEN :earliest_status_id AND :latest_status_id
      GROUP BY accounts.domain
      ORDER BY count(*) DESC
      LIMIT $3
      LIMIT :limit
    SQL
  end

    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at)], [nil, Mastodon::Snowflake.id_at(@end_at)], [nil, @limit]])
  def earliest_status_id
    Mastodon::Snowflake.id_at(@start_at)
  end

    rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
  def latest_status_id
    Mastodon::Snowflake.id_at(@end_at)
  end
end

M app/lib/admin/metrics/dimension/sources_dimension.rb => app/lib/admin/metrics/dimension/sources_dimension.rb +13 -7
@@ 1,6 1,8 @@
# frozen_string_literal: true

class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension
  include Admin::Metrics::Dimension::QueryHelper

  def key
    'sources'
  end


@@ 8,18 10,22 @@ class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::B
  protected

  def perform_query
    sql = <<-SQL.squish
    dimension_data_rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
  end

  def sql_array
    [sql_query_string, { start_at: @start_at, end_at: @end_at, limit: @limit }]
  end

  def sql_query_string
    <<~SQL.squish
      SELECT oauth_applications.name, count(*) AS value
      FROM users
      LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id
      WHERE users.created_at BETWEEN $1 AND $2
      WHERE users.created_at BETWEEN :start_at AND :end_at
      GROUP BY oauth_applications.name
      ORDER BY count(*) DESC
      LIMIT $3
      LIMIT :limit
    SQL

    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])

    rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
  end
end

M app/lib/admin/metrics/dimension/tag_languages_dimension.rb => app/lib/admin/metrics/dimension/tag_languages_dimension.rb +23 -6
@@ 1,6 1,7 @@
# frozen_string_literal: true

class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
  include Admin::Metrics::Dimension::QueryHelper
  include LanguagesHelper

  def self.with_params?


@@ 14,20 15,36 @@ class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimensi
  protected

  def perform_query
    sql = <<-SQL.squish
    dimension_data_rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
  end

  def sql_array
    [sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
  end

  def sql_query_string
    <<~SQL.squish
      SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
      FROM statuses
      INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
      WHERE statuses_tags.tag_id = $1
        AND statuses.id BETWEEN $2 AND $3
      WHERE statuses_tags.tag_id = :tag_id
        AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
      GROUP BY COALESCE(statuses.language, 'und')
      ORDER BY count(*) DESC
      LIMIT $4
      LIMIT :limit
    SQL
  end

  def tag_id
    params[:id]
  end

    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
  def earliest_status_id
    Mastodon::Snowflake.id_at(@start_at, with_random: false)
  end

    rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
  def latest_status_id
    Mastodon::Snowflake.id_at(@end_at, with_random: false)
  end

  def params

M app/lib/admin/metrics/dimension/tag_servers_dimension.rb => app/lib/admin/metrics/dimension/tag_servers_dimension.rb +24 -6
@@ 1,6 1,8 @@
# frozen_string_literal: true

class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension::BaseDimension
  include Admin::Metrics::Dimension::QueryHelper

  def self.with_params?
    true
  end


@@ 12,21 14,37 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension
  protected

  def perform_query
    sql = <<-SQL.squish
    dimension_data_rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
  end

  def sql_array
    [sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
  end

  def sql_query_string
    <<-SQL.squish
      SELECT accounts.domain, count(*) AS value
      FROM statuses
      INNER JOIN accounts ON accounts.id = statuses.account_id
      INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
      WHERE statuses_tags.tag_id = $1
        AND statuses.id BETWEEN $2 AND $3
      WHERE statuses_tags.tag_id = :tag_id
        AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
      GROUP BY accounts.domain
      ORDER BY count(*) DESC
      LIMIT $4
      LIMIT :limit
    SQL
  end

  def tag_id
    params[:id]
  end

    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
  def earliest_status_id
    Mastodon::Snowflake.id_at(@start_at, with_random: false)
  end

    rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
  def latest_status_id
    Mastodon::Snowflake.id_at(@end_at, with_random: false)
  end

  def params

M app/lib/feed_manager.rb => app/lib/feed_manager.rb +15 -11
@@ 40,9 40,9 @@ class FeedManager
  def filter?(timeline_type, status, receiver)
    case timeline_type
    when :home
      filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]))
      filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), :home)
    when :list
      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), :list)
    when :mentions
      filter_from_mentions?(status, receiver.id)
    when :direct


@@ 401,10 401,11 @@ class FeedManager
  # @param [Integer] receiver_id
  # @param [Hash] crutches
  # @return [Boolean]
  def filter_from_home?(status, receiver_id, crutches)
  def filter_from_home?(status, receiver_id, crutches, timeline_type = :home)
    return false if receiver_id == status.account_id
    return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
    return true  if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
    return true if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present?
    return true if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)

    check_for_blocks = crutches[:active_mentions][status.id] || []
    check_for_blocks.push(status.account_id)


@@ 603,13 604,16 @@ class FeedManager
      arr
    end

    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.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)
    crutches[:blocked_by]      = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| [s.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true)
    lists = List.where(account_id: receiver_id, exclusive: 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.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)
    crutches[:blocked_by]           = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| [s.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true)
    crutches[:exclusive_list_users] = ListAccount.where(list: lists, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true)

    crutches
  end

M app/models/list.rb => app/models/list.rb +1 -0
@@ 10,6 10,7 @@
#  created_at     :datetime         not null
#  updated_at     :datetime         not null
#  replies_policy :integer          default("list"), not null
#  exclusive      :boolean          default(FALSE)
#

class List < ApplicationRecord

M app/serializers/rest/list_serializer.rb => app/serializers/rest/list_serializer.rb +1 -1
@@ 1,7 1,7 @@
# frozen_string_literal: true

class REST::ListSerializer < ActiveModel::Serializer
  attributes :id, :title, :replies_policy
  attributes :id, :title, :replies_policy, :exclusive

  def id
    object.id.to_s

M app/views/admin/email_domain_blocks/_email_domain_block.html.haml => app/views/admin/email_domain_blocks/_email_domain_block.html.haml +1 -1
@@ 9,6 9,6 @@

      - if email_domain_block.parent.present?
        = t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))
        ·

      = t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })

M app/views/admin/export_domain_blocks/_domain_block.html.haml => app/views/admin/export_domain_blocks/_domain_block.html.haml +3 -3
@@ 17,11 17,11 @@

      %br/

      = f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
      = f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
      - if f.object.public_comment.present?
        ·
        = f.object.public_comment
      - if existing_relationships
        ·
        = fa_icon 'warning fw'
        = t('admin.export_domain_blocks.import.existing_relationships_warning')

M app/views/admin/instances/_instance.html.haml => app/views/admin/instances/_instance.html.haml +1 -1
@@ 6,7 6,7 @@

      %small
        - if instance.domain_block
          = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
          = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
        - elsif instance.domain_allow
          = t('admin.accounts.whitelisted')
        - else

M app/views/admin/instances/show.html.haml => app/views/admin/instances/show.html.haml +1 -1
@@ 55,7 55,7 @@
            %td= @instance.domain_block.public_comment
          %tr
            %th= t('admin.instances.content_policies.policy')
            %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
            %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')

    = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button'
    = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }

M app/views/admin/ip_blocks/_ip_block.html.haml => app/views/admin/ip_blocks/_ip_block.html.haml +1 -1
@@ 5,7 5,7 @@
    .pending-account__header
      %samp= link_to "#{ip_block.ip}/#{ip_block.ip.prefix}", admin_accounts_path(ip: "#{ip_block.ip}/#{ip_block.ip.prefix}")
      - if ip_block.comment.present?
        ·
        = ip_block.comment
      %br/
      = t("simple_form.labels.ip_block.severities.#{ip_block.severity}")

M app/views/admin/roles/_role.html.haml => app/views/admin/roles/_role.html.haml +1 -1
@@ 24,7 24,7 @@
        = t('admin.roles.everyone_full_description_html')
      - else
        = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id)
        ·
        %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
    %div
      = table_link_to 'pencil', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role)

M app/views/admin/settings/content_retention/show.html.haml => app/views/admin/settings/content_retention/show.html.haml +1 -1
@@ 12,7 12,7 @@

  .fields-group
    = f.input :media_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }
    = f.input :content_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }
    = f.input :content_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }, hint: false, warning_hint: t('simple_form.hints.form_admin_settings.content_cache_retention_period')
    = f.input :backups_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }

  .actions

M app/views/admin/trends/links/_preview_card.html.haml => app/views/admin/trends/links/_preview_card.html.haml +5 -5
@@ 10,21 10,21 @@

      - if preview_card.provider_name.present?
        = preview_card.provider_name
        ·

      - if preview_card.language.present?
        = standard_locale_name(preview_card.language)
        ·

      = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })

      - if preview_card.trend.allowed?
        ·
        %abbr{ title: t('admin.trends.tags.current_score', score: preview_card.trend.score) }= t('admin.trends.tags.trending_rank', rank: preview_card.trend.rank)

        - if preview_card.decaying?
          ·
          = t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short))
      - elsif preview_card.requires_review?
        ·
        = t('admin.trends.pending_review')

M app/views/admin/trends/statuses/_status.html.haml => app/views/admin/trends/statuses/_status.html.haml +5 -5
@@ 17,17 17,17 @@
    = t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count))

    - if status.account.domain.present?
      ·
      = status.account.domain
    - if status.language.present?
      ·
      = standard_locale_name(status.language)
    - if status.trendable? && !status.account.discoverable?
      ·
      = t('admin.trends.statuses.not_discoverable')
    - if status.trend.allowed?
      ·
      %abbr{ title: t('admin.trends.tags.current_score', score: status.trend.score) }= t('admin.trends.tags.trending_rank', rank: status.trend.rank)
    - elsif status.requires_review?
      ·
      = t('admin.trends.pending_review')

M app/views/admin/trends/tags/_tag.html.haml => app/views/admin/trends/tags/_tag.html.haml +3 -3
@@ 13,12 13,12 @@
      = t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts })

      - if tag.trendable? && (rank = Trends.tags.rank(tag.id))
        ·
        %abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)

        - if tag.decaying?
          ·
          = t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short))
      - elsif tag.requires_review?
        ·
        = t('admin.trends.pending_review')

M app/views/admin/webhooks/_webhook.html.haml => app/views/admin/webhooks/_webhook.html.haml +1 -1
@@ 10,7 10,7 @@
      - else
        %span.negative-hint= t('admin.webhooks.disabled')

      ·

      %abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size)


M app/views/admin_mailer/_new_trending_links.text.erb => app/views/admin_mailer/_new_trending_links.text.erb +2 -2
@@ 1,8 1,8 @@
<%= raw t('admin_mailer.new_trends.new_trending_links.title') %>

<% @links.each do |link| %>
- <%= link.title %> • <%= link.url %>
  <%= standard_locale_name(link.language) %> • <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %>
- <%= link.title %> · <%= link.url %>
  <%= standard_locale_name(link.language) %> · <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %>
<% end %>

<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>

M app/views/admin_mailer/_new_trending_statuses.text.erb => app/views/admin_mailer/_new_trending_statuses.text.erb +1 -1
@@ 2,7 2,7 @@

<% @statuses.each do |status| %>
- <%= ActivityPub::TagManager.instance.url_for(status) %>
  <%= standard_locale_name(status.language) %> • <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %>
  <%= standard_locale_name(status.language) %> · <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %>
<% end %>

<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>

M app/views/admin_mailer/_new_trending_tags.text.erb => app/views/admin_mailer/_new_trending_tags.text.erb +1 -1
@@ 2,7 2,7 @@

<% @tags.each do |tag| %>
- #<%= tag.display_name %>
  <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
  <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
<% end %>

<% if @lowest_trending_tag %>

M app/views/application/_card.html.haml => app/views/application/_card.html.haml +4 -2
@@ 1,9 1,11 @@
- account_url = local_assigns[:admin] ? admin_account_path(account.id) : ActivityPub::TagManager.instance.url_for(account)
- compact ||= false

.card.h-card
  = link_to account_url, target: '_blank', rel: 'noopener noreferrer' do
    .card__img
      = image_tag account.header.url, alt: ''
    - unless compact
      .card__img
        = image_tag account.header.url, alt: ''
    .card__bar
      .avatar
        = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'

M app/views/auth/registrations/rules.html.haml => app/views/auth/registrations/rules.html.haml +8 -2
@@ 7,8 7,14 @@
.simple_form
  = render 'auth/shared/progress', stage: 'rules'

  %h1.title= t('auth.rules.title')
  %p.lead= t('auth.rules.preamble', domain: site_hostname)
  - if @invite.present? && @invite.autofollow?
    %h1.title= t('auth.rules.title_invited')
    %p.lead.invited-by= t('auth.rules.invited_by', domain: site_hostname)
    = render 'application/card', account: @invite.user.account, compact: true
    %p.lead= t('auth.rules.preamble_invited', domain: site_hostname)
  - else
    %h1.title= t('auth.rules.title')
    %p.lead= t('auth.rules.preamble', domain: site_hostname)

  %ol.rules-list
    - @rules.each do |rule|

M app/views/oauth/authorized_applications/index.html.haml => app/views/oauth/authorized_applications/index.html.haml +1 -1
@@ 23,7 23,7 @@
          - else
            = t('doorkeeper.authorized_applications.index.never_used')

          ·

          = t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date))


M config/initializers/simple_form.rb => config/initializers/simple_form.rb +10 -0
@@ 19,6 19,14 @@ module RecommendedComponent
  end
end

module WarningHintComponent
  def warning_hint(_wrapper_options = nil)
    @warning_hint ||= begin
      options[:warning_hint].to_s.html_safe if options[:warning_hint].present?
    end
  end
end

module GlitchOnlyComponent
  def glitch_only(_wrapper_options = nil)
    return unless options[:glitch_only]


@@ 30,6 38,7 @@ end

SimpleForm.include_component(AppendComponent)
SimpleForm.include_component(RecommendedComponent)
SimpleForm.include_component(WarningHintComponent)
SimpleForm.include_component(GlitchOnlyComponent)

SimpleForm.setup do |config|


@@ 112,6 121,7 @@ SimpleForm.setup do |config|
    b.use :html5
    b.use :label
    b.use :hint, wrap_with: { tag: :span, class: :hint }
    b.use :warning_hint, wrap_with: { tag: :span, class: [:hint, 'warning-hint'] }
    b.use :input, wrap_with: { tag: :div, class: :label_input }
    b.use :error, wrap_with: { tag: :span, class: :error }
  end

D config/initializers/statsd.rb => config/initializers/statsd.rb +0 -15
@@ 1,15 0,0 @@
# frozen_string_literal: true

if ENV['STATSD_ADDR'].present?
  host, port = ENV['STATSD_ADDR'].split(':')

  $statsd = ::Statsd.new(host, port)
  $statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }

  ::NSA.inform_statsd($statsd) do |informant|
    informant.collect(:action_controller, :web)
    informant.collect(:active_record, :db)
    informant.collect(:active_support_cache, :cache)
    informant.collect(:sidekiq, :sidekiq)
  end
end

M config/locales/en.yml => config/locales/en.yml +3 -0
@@ 1031,8 1031,11 @@ en:
    rules:
      accept: Accept
      back: Back
      invited_by: 'You can join %{domain} thanks to the invitation you have received from:'
      preamble: These are set and enforced by the %{domain} moderators.
      preamble_invited: Before you proceed, please consider the ground rules set by the moderators of %{domain}.
      title: Some ground rules.
      title_invited: You've been invited.
    security: Security
    set_new_password: Set new password
    setup:

M config/locales/simple_form.en.yml => config/locales/simple_form.en.yml +1 -1
@@ 78,7 78,7 @@ en:
        backups_retention_period: Keep generated user archives for the specified number of days.
        bootstrap_timeline_accounts: These accounts will be pinned to the top of new users' follow recommendations.
        closed_registrations_message: Displayed when sign-ups are closed
        content_cache_retention_period: Posts from other servers will be deleted after the specified number of days when set to a positive value. This may be irreversible.
        content_cache_retention_period: All posts and boosts from other servers will be deleted after the specified number of days. Some posts may not be recoverable. All related bookmarks, favourites and boosts will also be lost and impossible to undo.
        custom_css: You can apply custom styles on the web version of Mastodon.
        mascot: Overrides the illustration in the advanced web interface.
        media_cache_retention_period: Downloaded media files will be deleted after the specified number of days when set to a positive value, and re-downloaded on demand.

D config/webpack/translationRunner.js => config/webpack/translationRunner.js +0 -3
@@ 1,3 0,0 @@
console.error("The localisation functionality has been refactored, please see the Localisation section in the development documentation (https://docs.joinmastodon.org/dev/code/#localizations)");

process.exit(1);

A db/migrate/20230605085710_add_exclusive_to_lists.rb => db/migrate/20230605085710_add_exclusive_to_lists.rb +7 -0
@@ 0,0 1,7 @@
# frozen_string_literal: true

class AddExclusiveToLists < ActiveRecord::Migration[6.1]
  def change
    add_column :lists, :exclusive, :boolean, null: false, default: false
  end
end

M db/schema.rb => db/schema.rb +2 -1
@@ 10,7 10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2023_05_31_154811) do
ActiveRecord::Schema.define(version: 2023_06_05_085710) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"


@@ 567,6 567,7 @@ ActiveRecord::Schema.define(version: 2023_05_31_154811) do
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.integer "replies_policy", default: 0, null: false
    t.boolean "exclusive", default: false
    t.index ["account_id"], name: "index_lists_on_account_id"
  end


A lib/linter/haml_middle_dot.rb => lib/linter/haml_middle_dot.rb +26 -0
@@ 0,0 1,26 @@
# frozen_string_literal: true

module HamlLint
  # Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in anything that will end up as a text node. (including string literals in Ruby code)
  class Linter::MiddleDot < Linter
    include LinterRegistry

    # rubocop:disable Style/MiddleDot
    BULLET = '•'
    # rubocop:enable Style/MiddleDot
    MIDDLE_DOT = '·'
    MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze

    def visit_plain(node)
      return unless node.text.include?(BULLET)

      record_lint(node, MESSAGE)
    end

    def visit_script(node)
      return unless node.script.include?(BULLET)

      record_lint(node, MESSAGE)
    end
  end
end

A lib/linter/rubocop_middle_dot.rb => lib/linter/rubocop_middle_dot.rb +31 -0
@@ 0,0 1,31 @@
# frozen_string_literal: true

module RuboCop
  module Cop
    module Style
      # Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in string literals
      class MiddleDot < Base
        extend AutoCorrector
        extend Util

        # rubocop:disable Style/MiddleDot
        BULLET = '•'
        # rubocop:enable Style/MiddleDot
        MIDDLE_DOT = '·'
        MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze

        def on_str(node)
          # Constants like __FILE__ are handled as strings,
          # but don't respond to begin.
          return unless node.loc.respond_to?(:begin) && node.loc.begin

          return unless node.value.include?(BULLET)

          add_offense(node, message: MESSAGE) do |corrector|
            corrector.replace(node, node.source.gsub(BULLET, MIDDLE_DOT))
          end
        end
      end
    end
  end
end

M package.json => package.json +0 -1
@@ 21,7 21,6 @@
    "lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"",
    "lint:yml": "prettier --check \"**/*.{yaml,yml}\"",
    "lint": "yarn lint:js && yarn lint:json && yarn lint:sass && yarn lint:yml",
    "manage:translations": "node ./config/webpack/translationRunner.js",
    "postversion": "git push --tags",
    "prepare": "husky install",
    "start": "node ./streaming/index.js",

A spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb => spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::InstanceAccountsDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb => spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::InstanceLanguagesDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/languages_dimension_spec.rb => spec/lib/admin/metrics/dimension/languages_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::LanguagesDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/servers_dimension_spec.rb => spec/lib/admin/metrics/dimension/servers_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::ServersDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb => spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::SoftwareVersionsDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/sources_dimension_spec.rb => spec/lib/admin/metrics/dimension/sources_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::SourcesDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb => spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::SpaceUsageDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb => spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::TagLanguagesDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

A spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb => spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::Metrics::Dimension::TagServersDimension do
  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }

  let(:start_at) { 2.days.ago }
  let(:end_at) { Time.now.utc }
  let(:limit) { 10 }
  let(:params) { ActionController::Parameters.new }

  describe '#data' do
    it 'runs data query without error' do
      expect { dimension.data }.to_not raise_error
    end
  end
end

M spec/lib/feed_manager_spec.rb => spec/lib/feed_manager_spec.rb +37 -0
@@ 26,6 26,7 @@ RSpec.describe FeedManager do
    let(:alice) { Fabricate(:account, username: 'alice') }
    let(:bob)   { Fabricate(:account, username: 'bob', domain: 'example.com') }
    let(:jeff)  { Fabricate(:account, username: 'jeff') }
    let(:list) { Fabricate(:list, account: alice) }

    context 'with home feed' do
      it 'returns false for followee\'s status' do


@@ 160,6 161,42 @@ RSpec.describe FeedManager do
        status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de')
        expect(FeedManager.instance.filter?(:home, status, alice)).to be false
      end

      it 'returns true for post from followee on exclusive list' do
        list.exclusive = true
        alice.follow!(bob)
        list.accounts << bob
        allow(List).to receive(:where).and_return(list)
        status = Fabricate(:status, text: 'I post a lot', account: bob)
        expect(FeedManager.instance.filter?(:home, status, alice)).to be true
      end

      it 'returns true for reblog from followee on exclusive list' do
        list.exclusive = true
        alice.follow!(jeff)
        list.accounts << jeff
        allow(List).to receive(:where).and_return(list)
        status = Fabricate(:status, text: 'I post a lot', account: bob)
        reblog = Fabricate(:status, reblog: status, account: jeff)
        expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true
      end

      it 'returns false for post from followee on non-exclusive list' do
        list.exclusive = false
        alice.follow!(bob)
        list.accounts << bob
        status = Fabricate(:status, text: 'I post a lot', account: bob)
        expect(FeedManager.instance.filter?(:home, status, alice)).to be false
      end

      it 'returns false for reblog from followee on non-exclusive list' do
        list.exclusive = false
        alice.follow!(jeff)
        list.accounts << jeff
        status = Fabricate(:status, text: 'I post a lot', account: bob)
        reblog = Fabricate(:status, reblog: status, account: jeff)
        expect(FeedManager.instance.filter?(:home, reblog, alice)).to be false
      end
    end

    context 'with mentions feed' do

M spec/lib/mastodon/cli/accounts_spec.rb => spec/lib/mastodon/cli/accounts_spec.rb +250 -0
@@ 998,4 998,254 @@ describe Mastodon::CLI::Accounts do
      end
    end
  end

  describe '#merge' do
    shared_examples 'an account not found' do |acct|
      it 'exits with an error message indicating that there is no such account' do
        expect { cli.invoke(:merge, arguments) }.to output(
          a_string_including("No such account (#{acct})")
        ).to_stdout
          .and raise_error(SystemExit)
      end
    end

    context 'when "from_account" is not found' do
      let(:to_account) { Fabricate(:account, domain: 'example.com') }
      let(:arguments)  { ['non_existent_username@domain.com', "#{to_account.username}@#{to_account.domain}"] }

      it_behaves_like 'an account not found', 'non_existent_username@domain.com'
    end

    context 'when "from_account" is a local account' do
      let(:from_account) { Fabricate(:account, domain: nil, username: 'bob') }
      let(:to_account)   { Fabricate(:account, domain: 'example.com') }
      let(:arguments)    { [from_account.username, "#{to_account.username}@#{to_account.domain}"] }

      it_behaves_like 'an account not found', 'bob'
    end

    context 'when "to_account" is not found' do
      let(:from_account) { Fabricate(:account, domain: 'example.com') }
      let(:arguments)    { ["#{from_account.username}@#{from_account.domain}", 'non_existent_username'] }

      it_behaves_like 'an account not found', 'non_existent_username'
    end

    context 'when "to_account" is local' do
      let(:from_account) { Fabricate(:account, domain: 'example.com') }
      let(:to_account)   { Fabricate(:account, domain: nil, username: 'bob') }
      let(:arguments) do
        ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
      end

      it_behaves_like 'an account not found', 'bob@'
    end

    context 'when "from_account" and "to_account" public keys do not match' do
      let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'from_account') }
      let(:to_account)   { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'to_account') }
      let(:arguments) do
        ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
      end

      before do
        allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account)
        allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account)
      end

      it 'exits with an error message indicating that the accounts do not have the same pub key' do
        expect { cli.invoke(:merge, arguments) }.to output(
          a_string_including("Accounts don't have the same public key, might not be duplicates!\nOverride with --force")
        ).to_stdout
          .and raise_error(SystemExit)
      end

      context 'with --force option' do
        let(:options) { { force: true } }

        before do
          allow(to_account).to receive(:merge_with!)
          allow(from_account).to receive(:destroy)
        end

        it 'merges "from_account" into "to_account"' do
          cli.invoke(:merge, arguments, options)

          expect(to_account).to have_received(:merge_with!).with(from_account).once
        end

        it 'deletes "from_account"' do
          cli.invoke(:merge, arguments, options)

          expect(from_account).to have_received(:destroy).once
        end
      end
    end

    context 'when "from_account" and "to_account" public keys match' do
      let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'pub_key') }
      let(:to_account)   { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'pub_key') }
      let(:arguments) do
        ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
      end

      before do
        allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account)
        allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account)
        allow(to_account).to receive(:merge_with!)
        allow(from_account).to receive(:destroy)
      end

      it 'merges "from_account" into "to_account"' do
        cli.invoke(:merge, arguments)

        expect(to_account).to have_received(:merge_with!).with(from_account).once
      end

      it 'deletes "from_account"' do
        cli.invoke(:merge, arguments)

        expect(from_account).to have_received(:destroy)
      end
    end
  end

  describe '#cull' do
    let(:delete_account_service) { instance_double(DeleteAccountService, call: nil) }
    let!(:tom)                   { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com') }
    let!(:bob)                   { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org') }
    let!(:gon)                   { Fabricate(:account, updated_at: 15.days.ago, last_webfingered_at: 15.days.ago, username: 'gon', uri: 'https://example.net/users/gon', domain: 'example.net') }
    let!(:ana)                   { Fabricate(:account, username: 'ana', uri: 'https://example.com/users/ana', domain: 'example.com') }
    let!(:tales)                 { Fabricate(:account, updated_at: 10.days.ago, last_webfingered_at: nil, username: 'tales', uri: 'https://example.net/users/tales', domain: 'example.net') }

    before do
      allow(DeleteAccountService).to receive(:new).and_return(delete_account_service)
    end

    context 'when no domain is specified' do
      let(:scope) { Account.remote.where(protocol: :activitypub).partitioned }

      before do
        allow(cli).to receive(:parallelize_with_progress).and_yield(tom)
                                                         .and_yield(bob)
                                                         .and_yield(gon)
                                                         .and_yield(ana)
                                                         .and_yield(tales)
                                                         .and_return([5, 3])
        stub_request(:head, 'https://example.org/users/bob').to_return(status: 404)
        stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
        stub_request(:head, 'https://example.net/users/tales').to_return(status: 200)
      end

      it 'deletes all inactive remote accounts that longer exist in the origin server' do
        cli.cull

        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
        expect(delete_account_service).to have_received(:call).with(bob, reserve_username: false).once
        expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
      end

      it 'does not delete any active remote account that still exists in the origin server' do
        cli.cull

        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
        expect(delete_account_service).to_not have_received(:call).with(tom, reserve_username: false)
        expect(delete_account_service).to_not have_received(:call).with(ana, reserve_username: false)
        expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
      end

      it 'touches inactive remote accounts that have not been deleted' do
        allow(tales).to receive(:touch)

        cli.cull

        expect(tales).to have_received(:touch).once
      end

      it 'displays the summary correctly' do
        expect { cli.cull }.to output(
          a_string_including('Visited 5 accounts, removed 3')
        ).to_stdout
      end
    end

    context 'when a domain is specified' do
      let(:domain) { 'example.net' }
      let(:scope)  { Account.remote.where(protocol: :activitypub, domain: domain).partitioned }

      before do
        allow(cli).to receive(:parallelize_with_progress).and_yield(gon)
                                                         .and_yield(tales)
                                                         .and_return([2, 2])
        stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
        stub_request(:head, 'https://example.net/users/tales').to_return(status: 404)
      end

      it 'deletes inactive remote accounts that longer exist in the specified domain' do
        cli.cull(domain)

        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
        expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
        expect(delete_account_service).to have_received(:call).with(tales, reserve_username: false).once
      end

      it 'displays the summary correctly' do
        expect { cli.cull }.to output(
          a_string_including('Visited 2 accounts, removed 2')
        ).to_stdout
      end
    end

    context 'when a domain is unavailable' do
      shared_examples 'an unavailable domain' do
        before do
          allow(cli).to receive(:parallelize_with_progress).and_yield(tales).and_return([1, 0])
        end

        it 'skips accounts from the unavailable domain' do
          cli.cull

          expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
        end

        it 'displays the summary correctly' do
          expect { cli.cull }.to output(
            a_string_including("Visited 1 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
          ).to_stdout
        end
      end

      context 'when a connection timeout occurs' do
        before do
          stub_request(:head, 'https://example.net/users/tales').to_timeout
        end

        it_behaves_like 'an unavailable domain'
      end

      context 'when a connection error occurs' do
        before do
          stub_request(:head, 'https://example.net/users/tales').to_raise(HTTP::ConnectionError)
        end

        it_behaves_like 'an unavailable domain'
      end

      context 'when an ssl error occurs' do
        before do
          stub_request(:head, 'https://example.net/users/tales').to_raise(OpenSSL::SSL::SSLError)
        end

        it_behaves_like 'an unavailable domain'
      end

      context 'when a private network address error occurs' do
        before do
          stub_request(:head, 'https://example.net/users/tales').to_raise(Mastodon::PrivateNetworkAddressError)
        end

        it_behaves_like 'an unavailable domain'
      end
    end
  end
end

M spec/lib/mastodon/cli/canonical_email_blocks_spec.rb => spec/lib/mastodon/cli/canonical_email_blocks_spec.rb +48 -0
@@ 4,9 4,57 @@ require 'rails_helper'
require 'mastodon/cli/canonical_email_blocks'

describe Mastodon::CLI::CanonicalEmailBlocks do
  let(:cli) { described_class.new }

  describe '.exit_on_failure?' do
    it 'returns true' do
      expect(described_class.exit_on_failure?).to be true
    end
  end

  describe '#find' do
    let(:arguments) { ['user@example.com'] }

    context 'when a block is present' do
      before { Fabricate(:canonical_email_block, email: 'user@example.com') }

      it 'announces the presence of the block' do
        expect { cli.invoke(:find, arguments) }.to output(
          a_string_including('user@example.com is blocked')
        ).to_stdout
      end
    end

    context 'when a block is not present' do
      it 'announces the absence of the block' do
        expect { cli.invoke(:find, arguments) }.to output(
          a_string_including('user@example.com is not blocked')
        ).to_stdout
      end
    end
  end

  describe '#remove' do
    let(:arguments) { ['user@example.com'] }

    context 'when a block is present' do
      before { Fabricate(:canonical_email_block, email: 'user@example.com') }

      it 'removes the block' do
        expect { cli.invoke(:remove, arguments) }.to output(
          a_string_including('Unblocked user@example.com')
        ).to_stdout

        expect(CanonicalEmailBlock.matching_email('user@example.com')).to be_empty
      end
    end

    context 'when a block is not present' do
      it 'announces the absence of the block' do
        expect { cli.invoke(:remove, arguments) }.to output(
          a_string_including('user@example.com is not blocked')
        ).to_stdout
      end
    end
  end
end