~cytrogen/masto-fe

b735954971a3e191fbfa1860e871724fafb9363d — Claire 2 years ago 646cde7 + 2ce0b66
Merge commit '2ce0b666a139726dc406e6c1887728553b947e59' into glitch-soc/merge-upstream

Conflicts:
- `config/webpack/generateLocalePacks.js`:
  A dependency update changed how functions are imported.
  Also, some linting fixes not applicable to glitch-soc.
128 files changed, 1386 insertions(+), 553 deletions(-)

M .eslintrc.js
M .rubocop_todo.yml
M Gemfile.lock
M app/controllers/api/v1/admin/canonical_email_blocks_controller.rb
M app/javascript/mastodon/actions/app.ts
M app/javascript/mastodon/actions/pin_statuses.js
M app/javascript/mastodon/components/__tests__/display_name-test.jsx
M app/javascript/mastodon/components/account.jsx
M app/javascript/mastodon/components/animated_number.tsx
M app/javascript/mastodon/components/autosuggest_input.jsx
M app/javascript/mastodon/components/autosuggest_textarea.jsx
M app/javascript/mastodon/components/avatar.tsx
M app/javascript/mastodon/components/avatar_overlay.tsx
M app/javascript/mastodon/components/blurhash.tsx
D app/javascript/mastodon/components/display_name.jsx
A app/javascript/mastodon/components/display_name.tsx
M app/javascript/mastodon/components/domain.tsx
A app/javascript/mastodon/components/empty_account.tsx
M app/javascript/mastodon/components/gifv.tsx
M app/javascript/mastodon/components/icon.tsx
M app/javascript/mastodon/components/icon_button.tsx
M app/javascript/mastodon/components/icon_with_badge.tsx
R app/javascript/mastodon/components/{logo.jsx => logo.tsx}
M app/javascript/mastodon/components/media_gallery.jsx
M app/javascript/mastodon/components/modal_root.jsx
M app/javascript/mastodon/components/not_signed_in_indicator.tsx
M app/javascript/mastodon/components/radio_button.tsx
M app/javascript/mastodon/components/relative_timestamp.tsx
M app/javascript/mastodon/components/server_banner.jsx
R app/javascript/mastodon/components/{image => server_hero_image}.tsx
M app/javascript/mastodon/components/status.jsx
M app/javascript/mastodon/components/status_list.jsx
M app/javascript/mastodon/components/verified_badge.tsx
M app/javascript/mastodon/containers/media_container.jsx
M app/javascript/mastodon/containers/status_container.jsx
M app/javascript/mastodon/features/about/index.jsx
M app/javascript/mastodon/features/account/components/account_note.jsx
M app/javascript/mastodon/features/account_gallery/index.jsx
M app/javascript/mastodon/features/account_timeline/components/moved_note.jsx
M app/javascript/mastodon/features/account_timeline/index.jsx
M app/javascript/mastodon/features/audio/index.jsx
M app/javascript/mastodon/features/blocks/index.jsx
M app/javascript/mastodon/features/bookmarked_statuses/index.jsx
M app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
M app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
M app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx
M app/javascript/mastodon/features/compose/components/reply_indicator.jsx
M app/javascript/mastodon/features/directory/components/account_card.jsx
M app/javascript/mastodon/features/domain_blocks/index.jsx
M app/javascript/mastodon/features/favourited_statuses/index.jsx
M app/javascript/mastodon/features/favourites/index.jsx
M app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx
M app/javascript/mastodon/features/follow_requests/index.jsx
M app/javascript/mastodon/features/interaction_modal/index.jsx
M app/javascript/mastodon/features/list_adder/components/account.jsx
M app/javascript/mastodon/features/list_editor/components/account.jsx
M app/javascript/mastodon/features/list_timeline/index.jsx
M app/javascript/mastodon/features/lists/index.jsx
M app/javascript/mastodon/features/mutes/index.jsx
M app/javascript/mastodon/features/notifications/components/follow_request.jsx
M app/javascript/mastodon/features/notifications/index.jsx
M app/javascript/mastodon/features/onboarding/follows.jsx
M app/javascript/mastodon/features/onboarding/index.jsx
M app/javascript/mastodon/features/onboarding/share.jsx
M app/javascript/mastodon/features/picture_in_picture/components/header.jsx
M app/javascript/mastodon/features/pinned_statuses/index.jsx
M app/javascript/mastodon/features/reblogs/index.jsx
M app/javascript/mastodon/features/report/components/status_check_box.jsx
M app/javascript/mastodon/features/status/components/card.jsx
M app/javascript/mastodon/features/status/components/detailed_status.jsx
M app/javascript/mastodon/features/status/containers/detailed_status_container.js
M app/javascript/mastodon/features/status/index.jsx
M app/javascript/mastodon/features/ui/components/boost_modal.jsx
M app/javascript/mastodon/features/ui/components/bundle.jsx
M app/javascript/mastodon/features/ui/components/columns_area.jsx
M app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
M app/javascript/mastodon/features/ui/components/header.jsx
M app/javascript/mastodon/features/ui/components/media_modal.jsx
M app/javascript/mastodon/features/ui/components/navigation_panel.jsx
M app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
M app/javascript/mastodon/features/ui/components/upload_area.jsx
M app/javascript/mastodon/features/ui/containers/status_list_container.js
M app/javascript/mastodon/features/ui/index.jsx
M app/javascript/mastodon/features/video/index.jsx
M app/javascript/mastodon/is_mobile.ts
M app/javascript/mastodon/locales/defaultMessages.json
M app/javascript/mastodon/locales/en.json
M app/javascript/mastodon/locales/locale-data/co.js
M app/javascript/mastodon/locales/locale-data/oc.js
M app/javascript/mastodon/locales/locale-data/sa.js
M app/javascript/mastodon/polyfills/base_polyfills.ts
M app/javascript/mastodon/reducers/index.ts
M app/javascript/mastodon/reducers/markers.js
M app/javascript/mastodon/reducers/missed_updates.ts
M app/javascript/mastodon/store/index.ts
M app/javascript/mastodon/store/middlewares/errors.ts
M app/javascript/mastodon/store/middlewares/loading_bar.ts
M app/javascript/mastodon/store/middlewares/sounds.ts
M app/javascript/mastodon/uuid.ts
M app/javascript/styles/mastodon/components.scss
M app/javascript/types/resources.ts
M app/lib/account_reach_finder.rb
M app/lib/vacuum/access_tokens_vacuum.rb
M app/models/form/account_batch.rb
M app/views/admin/reports/_media_attachments.html.haml
M config/initializers/rack_attack.rb
M config/locales/devise.en.yml
M config/locales/en.yml
M config/webpack/generateLocalePacks.js
M package.json
M spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
M spec/controllers/activitypub/outboxes_controller_spec.rb
M spec/controllers/admin/disputes/appeals_controller_spec.rb
M spec/controllers/admin/reports/actions_controller_spec.rb
M spec/controllers/api/v1/admin/canonical_email_blocks_controller_spec.rb
M spec/controllers/auth/registrations_controller_spec.rb
M spec/fabricators/notification_fabricator.rb
A spec/lib/account_reach_finder_spec.rb
A spec/lib/mastodon/ip_blocks_cli_spec.rb
M spec/lib/vacuum/access_tokens_vacuum_spec.rb
A spec/locales/i18n_spec.rb
M spec/mailers/notification_mailer_spec.rb
M spec/mailers/user_mailer_spec.rb
A spec/models/form/account_batch_spec.rb
M spec/policies/report_note_policy_spec.rb
M spec/services/activitypub/process_account_service_spec.rb
M spec/services/unsuspend_account_service_spec.rb
M yarn.lock
M .eslintrc.js => .eslintrc.js +67 -6
@@ 55,10 55,7 @@ module.exports = {
      '\\.(css|scss|json)$',
    ],
    'import/resolver': {
      node: {
        paths: ['app/javascript'],
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
      typescript: {},
    },
  },



@@ 104,7 101,6 @@ module.exports = {
    'react/jsx-equals-spacing': 'error',
    'react/jsx-no-bind': 'error',
    'react/jsx-no-target-blank': 'off',
    'react/no-deprecated': 'off',
    'react/no-unknown-property': 'off',
    'react/self-closing-comp': 'error',



@@ 168,11 164,14 @@ module.exports = {
      {
        js: 'never',
        jsx: 'never',
        mjs: 'never',
        ts: 'never',
        tsx: 'never',
      },
    ],
    'import/first': 'error',
    'import/newline-after-import': 'error',
    'import/no-anonymous-default-export': 'error',
    'import/no-extraneous-dependencies': [
      'error',
      {


@@ 187,6 186,9 @@ module.exports = {
    'import/no-amd': 'error',
    'import/no-commonjs': 'error',
    'import/no-import-module-exports': 'error',
    'import/no-relative-packages': 'error',
    'import/no-self-import': 'error',
    'import/no-useless-path-segments': 'error',
    'import/no-webpack-loader-syntax': 'error',

    'promise/always-return': 'off',


@@ 258,6 260,7 @@ module.exports = {
      extends: [
        'eslint:recommended',
        'plugin:@typescript-eslint/recommended',
        'plugin:@typescript-eslint/recommended-requiring-type-checking',
        'plugin:react/recommended',
        'plugin:react-hooks/recommended',
        'plugin:jsx-a11y/recommended',


@@ 268,8 271,66 @@ module.exports = {
        'plugin:prettier/recommended',
      ],

      parserOptions: {
        project: './tsconfig.json',
        tsconfigRootDir: __dirname,
      },

      rules: {
        '@typescript-eslint/no-explicit-any': 'off',
        'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],

        'import/order': [
          'error',
          {
            alphabetize: { order: 'asc' },
            'newlines-between': 'always',
            groups: [
              'builtin',
              'external',
              'internal',
              'parent',
              ['index', 'sibling'],
              'object',
            ],
            pathGroups: [
              // React core packages
              {
                pattern: '{react,react-dom,prop-types}',
                group: 'builtin',
                position: 'after',
              },
              // I18n
              {
                pattern: 'react-intl',
                group: 'builtin',
                position: 'after',
              },
              // Common React utilities
              {
                pattern: '{classnames,react-helmet}',
                group: 'external',
                position: 'before',
              },
              // Immutable / Redux / data store
              {
                pattern: '{immutable,react-redux,react-immutable-proptypes,react-immutable-pure-component,reselect}',
                group: 'external',
                position: 'before',
              },
              // Internal packages
              {
                pattern: '{mastodon/**}',
                group: 'internal',
                position: 'after',
              },
            ],
            pathGroupsExcludedImportTypes: [],
          },
        ],

        '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
        '@typescript-eslint/consistent-type-exports': 'error',
        '@typescript-eslint/consistent-type-imports': 'error',

        'jsdoc/require-jsdoc': 'off',


M .rubocop_todo.yml => .rubocop_todo.yml +0 -23
@@ 94,11 94,6 @@ Lint/AmbiguousBlockAssociation:
    - 'spec/services/unsuspend_account_service_spec.rb'
    - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb'

# This cop supports safe autocorrection (--autocorrect).
Lint/AmbiguousOperatorPrecedence:
  Exclude:
    - 'config/initializers/rack_attack.rb'

# Configuration parameters: AllowComments, AllowEmptyLambdas.
Lint/EmptyBlock:
  Exclude:


@@ 646,24 641,6 @@ RSpec/RepeatedExampleGroupBody:
  Exclude:
    - 'spec/controllers/statuses_controller_spec.rb'

RSpec/RepeatedExampleGroupDescription:
  Exclude:
    - 'spec/controllers/admin/reports/actions_controller_spec.rb'
    - 'spec/policies/report_note_policy_spec.rb'

RSpec/ScatteredSetup:
  Exclude:
    - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb'
    - 'spec/controllers/activitypub/outboxes_controller_spec.rb'
    - 'spec/controllers/admin/disputes/appeals_controller_spec.rb'
    - 'spec/controllers/auth/registrations_controller_spec.rb'
    - 'spec/services/activitypub/process_account_service_spec.rb'

# This cop supports safe autocorrection (--autocorrect).
RSpec/SharedContext:
  Exclude:
    - 'spec/services/unsuspend_account_service_spec.rb'

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

M Gemfile.lock => Gemfile.lock +4 -4
@@ 166,7 166,7 @@ GEM
      sshkit (~> 1.3)
    capistrano-yarn (2.0.2)
      capistrano (~> 3.0)
    capybara (3.39.0)
    capybara (3.39.1)
      addressable
      matrix
      mini_mime (>= 0.1.3)


@@ 331,7 331,7 @@ GEM
    httplog (1.6.2)
      rack (>= 2.0)
      rainbow (>= 2.0.0)
    i18n (1.12.0)
    i18n (1.13.0)
      concurrent-ruby (~> 1.0)
    i18n-tasks (1.0.12)
      activesupport (>= 4.0.2)


@@ 418,7 418,7 @@ GEM
      mime-types-data (~> 3.2015)
    mime-types-data (3.2023.0218.1)
    mini_mime (1.1.2)
    mini_portile2 (2.8.1)
    mini_portile2 (2.8.2)
    minitest (5.18.0)
    msgpack (1.7.0)
    multi_json (1.15.0)


@@ 698,7 698,7 @@ GEM
      unicode-display_width (>= 1.1.1, < 3)
    terrapin (0.6.0)
      climate_control (>= 0.0.3, < 1.0)
    thor (1.2.1)
    thor (1.2.2)
    tilt (2.1.0)
    timeout (0.3.2)
    tpm-key_attestation (0.12.0)

M app/controllers/api/v1/admin/canonical_email_blocks_controller.rb => app/controllers/api/v1/admin/canonical_email_blocks_controller.rb +1 -1
@@ 58,7 58,7 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController
  end

  def set_canonical_email_blocks_from_test
    @canonical_email_blocks = CanonicalEmailBlock.matching_email(params[:email])
    @canonical_email_blocks = CanonicalEmailBlock.matching_email(params.require(:email))
  end

  def set_canonical_email_block

M app/javascript/mastodon/actions/app.ts => app/javascript/mastodon/actions/app.ts +3 -2
@@ 1,11 1,12 @@
import { createAction } from '@reduxjs/toolkit';

import type { LayoutType } from '../is_mobile';

export const focusApp = createAction('APP_FOCUS');
export const unfocusApp = createAction('APP_UNFOCUS');

type ChangeLayoutPayload = {
interface ChangeLayoutPayload {
  layout: LayoutType;
};
}
export const changeLayout =
  createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE');

M app/javascript/mastodon/actions/pin_statuses.js => app/javascript/mastodon/actions/pin_statuses.js +2 -2
@@ 1,12 1,12 @@
import api from '../api';
import { importFetchedStatuses } from './importer';

import { me } from '../initial_state';

export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';

import { me } from '../initial_state';

export function fetchPinnedStatuses() {
  return (dispatch, getState) => {
    dispatch(fetchPinnedStatusesRequest());

M app/javascript/mastodon/components/__tests__/display_name-test.jsx => app/javascript/mastodon/components/__tests__/display_name-test.jsx +1 -1
@@ 1,7 1,7 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS }  from 'immutable';
import DisplayName from '../display_name';
import { DisplayName } from '../display_name';

describe('<DisplayName />', () => {
  it('renders display name + account name', () => {

M app/javascript/mastodon/components/account.jsx => app/javascript/mastodon/components/account.jsx +3 -16
@@ 2,18 2,18 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { Avatar } from './avatar';
import DisplayName from './display_name';
import { DisplayName } from './display_name';
import { IconButton } from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
import { RelativeTimestamp } from './relative_timestamp';
import Skeleton from 'mastodon/components/skeleton';
import { Link } from 'react-router-dom';
import { counterRenderer } from 'mastodon/components/common_counter';
import ShortNumber from 'mastodon/components/short_number';
import classNames from 'classnames';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { EmptyAccount } from 'mastodon/components/empty_account';

const messages = defineMessages({
  follow: { id: 'account.follow', defaultMessage: 'Follow' },


@@ 77,20 77,7 @@ class Account extends ImmutablePureComponent {
    const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;

    if (!account) {
      return (
        <div className={classNames('account', { 'account--minimal': minimal })}>
          <div className='account__wrapper'>
            <div className='account__display-name'>
              <div className='account__avatar-wrapper'><Skeleton width={size} height={size} /></div>

              <div>
                <DisplayName />
                <Skeleton width='7ch' />
              </div>
            </div>
          </div>
        </div>
      );
      return <EmptyAccount size={size} minimal={minimal} />;
    }

    if (hidden) {

M app/javascript/mastodon/components/animated_number.tsx => app/javascript/mastodon/components/animated_number.tsx +11 -4
@@ 1,8 1,11 @@
import React, { useCallback, useState } from 'react';
import ShortNumber from './short_number';

import { TransitionMotion, spring } from 'react-motion';

import { reduceMotion } from '../initial_state';

import ShortNumber from './short_number';

const obfuscatedCount = (count: number) => {
  if (count < 0) {
    return 0;


@@ 13,10 16,10 @@ const obfuscatedCount = (count: number) => {
  }
};

type Props = {
interface Props {
  value: number;
  obfuscate?: boolean;
};
}
export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
  const [previousValue, setPreviousValue] = useState(value);
  const [direction, setDirection] = useState<1 | -1>(1);


@@ 64,7 67,11 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
                transform: `translateY(${style.y * 100}%)`,
              }}
            >
              {obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}
              {obfuscate ? (
                obfuscatedCount(data as number)
              ) : (
                <ShortNumber value={data as number} />
              )}
            </span>
          ))}
        </span>

M app/javascript/mastodon/components/autosuggest_input.jsx => app/javascript/mastodon/components/autosuggest_input.jsx +1 -1
@@ 154,7 154,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
    this.input.focus();
  };

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
      this.setState({ suggestionsHidden: false });
    }

M app/javascript/mastodon/components/autosuggest_textarea.jsx => app/javascript/mastodon/components/autosuggest_textarea.jsx +1 -1
@@ 153,7 153,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
    this.textarea.focus();
  };

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
      this.setState({ suggestionsHidden: false });
    }

M app/javascript/mastodon/components/avatar.tsx => app/javascript/mastodon/components/avatar.tsx +5 -3
@@ 1,16 1,18 @@
import * as React from 'react';

import classNames from 'classnames';
import { autoPlayGif } from '../initial_state';

import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';

type Props = {
interface Props {
  account: Account;
  size: number;
  style?: React.CSSProperties;
  inline?: boolean;
  animate?: boolean;
};
}

export const Avatar: React.FC<Props> = ({
  account,

M app/javascript/mastodon/components/avatar_overlay.tsx => app/javascript/mastodon/components/avatar_overlay.tsx +4 -3
@@ 1,15 1,16 @@
import React from 'react';
import type { Account } from '../../types/resources';

import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';

type Props = {
interface Props {
  account: Account;
  friend: Account;
  size?: number;
  baseSize?: number;
  overlaySize?: number;
};
}

export const AvatarOverlay: React.FC<Props> = ({
  account,

M app/javascript/mastodon/components/blurhash.tsx => app/javascript/mastodon/components/blurhash.tsx +5 -4
@@ 1,14 1,14 @@
import { decode } from 'blurhash';
import React, { useRef, useEffect } from 'react';

type Props = {
import { decode } from 'blurhash';

interface Props extends React.HTMLAttributes<HTMLCanvasElement> {
  hash: string;
  width?: number;
  height?: number;
  dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
  children?: never;
  [key: string]: any;
};
}
const Blurhash: React.FC<Props> = ({
  hash,
  width = 32,


@@ 21,6 21,7 @@ const Blurhash: React.FC<Props> = ({
  useEffect(() => {
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    const canvas = canvasRef.current!;

    // eslint-disable-next-line no-self-assign
    canvas.width = canvas.width; // resets canvas


D app/javascript/mastodon/components/display_name.jsx => app/javascript/mastodon/components/display_name.jsx +0 -79
@@ 1,79 0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { autoPlayGif } from 'mastodon/initial_state';
import Skeleton from 'mastodon/components/skeleton';

export default class DisplayName extends React.PureComponent {

  static propTypes = {
    account: ImmutablePropTypes.map,
    others: ImmutablePropTypes.list,
    localDomain: PropTypes.string,
  };

  handleMouseEnter = ({ currentTarget }) => {
    if (autoPlayGif) {
      return;
    }

    const emojis = currentTarget.querySelectorAll('.custom-emoji');

    for (var i = 0; i < emojis.length; i++) {
      let emoji = emojis[i];
      emoji.src = emoji.getAttribute('data-original');
    }
  };

  handleMouseLeave = ({ currentTarget }) => {
    if (autoPlayGif) {
      return;
    }

    const emojis = currentTarget.querySelectorAll('.custom-emoji');

    for (var i = 0; i < emojis.length; i++) {
      let emoji = emojis[i];
      emoji.src = emoji.getAttribute('data-static');
    }
  };

  render () {
    const { others, localDomain } = this.props;

    let displayName, suffix, account;

    if (others && others.size > 1) {
      displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);

      if (others.size - 2 > 0) {
        suffix = `+${others.size - 2}`;
      }
    } else if ((others && others.size > 0) || this.props.account) {
      if (others && others.size > 0) {
        account = others.first();
      } else {
        account = this.props.account;
      }

      let acct = account.get('acct');

      if (acct.indexOf('@') === -1 && localDomain) {
        acct = `${acct}@${localDomain}`;
      }

      displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
      suffix      = <span className='display-name__account'>@{acct}</span>;
    } else {
      displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
      suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
    }

    return (
      <span className='display-name' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
        {displayName} {suffix}
      </span>
    );
  }

}

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

import type { List } from 'immutable';

import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';

import Skeleton from './skeleton';

interface Props {
  account?: Account;
  others?: List<Account>;
  localDomain?: string;
}

export class DisplayName extends React.PureComponent<Props> {
  handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
    currentTarget,
  }) => {
    if (autoPlayGif) {
      return;
    }

    const emojis =
      currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');

    emojis.forEach((emoji) => {
      const originalSrc = emoji.getAttribute('data-original');
      if (originalSrc != null) emoji.src = originalSrc;
    });
  };

  handleMouseLeave: React.ReactEventHandler<HTMLSpanElement> = ({
    currentTarget,
  }) => {
    if (autoPlayGif) {
      return;
    }

    const emojis =
      currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');

    emojis.forEach((emoji) => {
      const staticSrc = emoji.getAttribute('data-static');
      if (staticSrc != null) emoji.src = staticSrc;
    });
  };

  render() {
    const { others, localDomain } = this.props;

    let displayName: React.ReactNode,
      suffix: React.ReactNode,
      account: Account | undefined;

    if (others && others.size > 0) {
      account = others.first();
    } else if (this.props.account) {
      account = this.props.account;
    }

    if (others && others.size > 1) {
      displayName = others
        .take(2)
        .map((a) => (
          <bdi key={a.get('id')}>
            <strong
              className='display-name__html'
              dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
            />
          </bdi>
        ))
        .reduce((prev, cur) => [prev, ', ', cur]);

      if (others.size - 2 > 0) {
        suffix = `+${others.size - 2}`;
      }
    } else if (account) {
      let acct = account.get('acct');

      if (acct.indexOf('@') === -1 && localDomain) {
        acct = `${acct}@${localDomain}`;
      }

      displayName = (
        <bdi>
          <strong
            className='display-name__html'
            dangerouslySetInnerHTML={{
              __html: account.get('display_name_html'),
            }}
          />
        </bdi>
      );
      suffix = <span className='display-name__account'>@{acct}</span>;
    } else {
      displayName = (
        <bdi>
          <strong className='display-name__html'>
            <Skeleton width='10ch' />
          </strong>
        </bdi>
      );
      suffix = (
        <span className='display-name__account'>
          <Skeleton width='7ch' />
        </span>
      );
    }

    return (
      <span
        className='display-name'
        onMouseEnter={this.handleMouseEnter}
        onMouseLeave={this.handleMouseLeave}
      >
        {displayName} {suffix}
      </span>
    );
  }
}

M app/javascript/mastodon/components/domain.tsx => app/javascript/mastodon/components/domain.tsx +6 -3
@@ 1,6 1,9 @@
import React, { useCallback } from 'react';

import type { InjectedIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';

import { IconButton } from './icon_button';
import { InjectedIntl, defineMessages, injectIntl } from 'react-intl';

const messages = defineMessages({
  unblockDomain: {


@@ 9,11 12,11 @@ const messages = defineMessages({
  },
});

type Props = {
interface Props {
  domain: string;
  onUnblockDomain: (domain: string) => void;
  intl: InjectedIntl;
};
}
const _Domain: React.FC<Props> = ({ domain, onUnblockDomain, intl }) => {
  const handleDomainUnblock = useCallback(() => {
    onUnblockDomain(domain);

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

import classNames from 'classnames';

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

interface Props {
  size?: number;
  minimal?: boolean;
}

export const EmptyAccount: React.FC<Props> = ({
  size = 46,
  minimal = false,
}) => {
  return (
    <div className={classNames('account', { 'account--minimal': minimal })}>
      <div className='account__wrapper'>
        <div className='account__display-name'>
          <div className='account__avatar-wrapper'>
            <Skeleton width={size} height={size} />
          </div>

          <div>
            <DisplayName />
            <Skeleton width='7ch' />
          </div>
        </div>
      </div>
    </div>
  );
};

M app/javascript/mastodon/components/gifv.tsx => app/javascript/mastodon/components/gifv.tsx +2 -2
@@ 1,6 1,6 @@
import React, { useCallback, useState } from 'react';

type Props = {
interface Props {
  src: string;
  key: string;
  alt?: string;


@@ 8,7 8,7 @@ type Props = {
  width: number;
  height: number;
  onClick?: () => void;
};
}

export const GIFV: React.FC<Props> = ({
  src,

M app/javascript/mastodon/components/icon.tsx => app/javascript/mastodon/components/icon.tsx +4 -3
@@ 1,13 1,14 @@
import React from 'react';

import classNames from 'classnames';

type Props = {
interface Props extends React.HTMLAttributes<HTMLImageElement> {
  id: string;
  className?: string;
  fixedWidth?: boolean;
  children?: never;
  [key: string]: any;
};
}

export const Icon: React.FC<Props> = ({
  id,
  className,

M app/javascript/mastodon/components/icon_button.tsx => app/javascript/mastodon/components/icon_button.tsx +7 -5
@@ 1,9 1,11 @@
import React from 'react';

import classNames from 'classnames';
import { Icon } from './icon';

import { AnimatedNumber } from './animated_number';
import { Icon } from './icon';

type Props = {
interface Props {
  className?: string;
  title: string;
  icon: string;


@@ 25,11 27,11 @@ type Props = {
  obfuscateCount?: boolean;
  href?: string;
  ariaHidden: boolean;
};
type States = {
}
interface States {
  activate: boolean;
  deactivate: boolean;
};
}
export class IconButton extends React.PureComponent<Props, States> {
  static defaultProps = {
    size: 18,

M app/javascript/mastodon/components/icon_with_badge.tsx => app/javascript/mastodon/components/icon_with_badge.tsx +3 -2
@@ 1,14 1,15 @@
import React from 'react';

import { Icon } from './icon';

const formatNumber = (num: number): number | string => (num > 40 ? '40+' : num);

type Props = {
interface Props {
  id: string;
  count: number;
  issueBadge: boolean;
  className: string;
};
}
export const IconWithBadge: React.FC<Props> = ({
  id,
  count,

R app/javascript/mastodon/components/logo.jsx => app/javascript/mastodon/components/logo.tsx +3 -4
@@ 1,15 1,14 @@
import React from 'react';

import logo from 'mastodon/../images/logo.svg';

export const WordmarkLogo = () => (
export const WordmarkLogo: React.FC = () => (
  <svg viewBox='0 0 261 66' className='logo logo--wordmark' role='img'>
    <title>Mastodon</title>
    <use xlinkHref='#logo-symbol-wordmark' />
  </svg>
);

export const SymbolLogo = () => (
export const SymbolLogo: React.FC = () => (
  <img src={logo} alt='Mastodon' className='logo logo--icon' />
);

export default WordmarkLogo;

M app/javascript/mastodon/components/media_gallery.jsx => app/javascript/mastodon/components/media_gallery.jsx +2 -2
@@ 231,7 231,7 @@ class MediaGallery extends React.PureComponent {
    window.removeEventListener('resize', this.handleResize);
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
      this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
    } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {


@@ 256,7 256,7 @@ class MediaGallery extends React.PureComponent {
  };

  handleClick = (index) => {
    this.props.onOpenMedia(this.props.media, index);
    this.props.onOpenMedia(this.props.media, index, this.props.lang);
  };

  handleRef = c => {

M app/javascript/mastodon/components/modal_root.jsx => app/javascript/mastodon/components/modal_root.jsx +1 -1
@@ 57,7 57,7 @@ export default class ModalRoot extends React.PureComponent {
    this.history = this.context.router ? this.context.router.history : createBrowserHistory();
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (!!nextProps.children && !this.props.children) {
      this.activeElement = document.activeElement;


M app/javascript/mastodon/components/not_signed_in_indicator.tsx => app/javascript/mastodon/components/not_signed_in_indicator.tsx +2 -1
@@ 1,4 1,5 @@
import React from 'react';

import { FormattedMessage } from 'react-intl';

export const NotSignedInIndicator: React.FC = () => (


@@ 6,7 7,7 @@ export const NotSignedInIndicator: React.FC = () => (
    <div className='empty-column-indicator'>
      <FormattedMessage
        id='not_signed_in_indicator.not_signed_in'
        defaultMessage='You need to sign in to access this resource.'
        defaultMessage='You need to login to access this resource.'
      />
    </div>
  </div>

M app/javascript/mastodon/components/radio_button.tsx => app/javascript/mastodon/components/radio_button.tsx +3 -2
@@ 1,13 1,14 @@
import React from 'react';

import classNames from 'classnames';

type Props = {
interface Props {
  value: string;
  checked: boolean;
  name: string;
  onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  label: React.ReactNode;
};
}

export const RadioButton: React.FC<Props> = ({
  name,

M app/javascript/mastodon/components/relative_timestamp.tsx => app/javascript/mastodon/components/relative_timestamp.tsx +7 -5
@@ 1,5 1,7 @@
import React from 'react';
import { injectIntl, defineMessages, InjectedIntl } from 'react-intl';

import type { InjectedIntl } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl';

const messages = defineMessages({
  today: { id: 'relative_time.today', defaultMessage: 'today' },


@@ 187,16 189,16 @@ const timeRemainingString = (
  return relativeTime;
};

type Props = {
interface Props {
  intl: InjectedIntl;
  timestamp: string;
  year: number;
  futureDate?: boolean;
  short?: boolean;
};
type States = {
}
interface States {
  now: number;
};
}
class RelativeTimestamp extends React.Component<Props, States> {
  state = {
    now: this.props.intl.now(),

M app/javascript/mastodon/components/server_banner.jsx => app/javascript/mastodon/components/server_banner.jsx +2 -2
@@ 7,7 7,7 @@ import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import Account from 'mastodon/containers/account_container';
import { domain } from 'mastodon/initial_state';
import { Image } from 'mastodon/components/image';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { Link } from 'react-router-dom';

const messages = defineMessages({


@@ 41,7 41,7 @@ class ServerBanner extends React.PureComponent {
          <FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
        </div>

        <Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
        <ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />

        <div className='server-banner__description'>
          {isLoading ? (

R app/javascript/mastodon/components/image.tsx => app/javascript/mastodon/components/server_hero_image.tsx +6 -4
@@ 1,15 1,17 @@
import React, { useCallback, useState } from 'react';
import { Blurhash } from './blurhash';

import classNames from 'classnames';

type Props = {
import { Blurhash } from './blurhash';

interface Props {
  src: string;
  srcSet?: string;
  blurhash?: string;
  className?: string;
};
}

export const Image: React.FC<Props> = ({
export const ServerHeroImage: React.FC<Props> = ({
  src,
  srcSet,
  blurhash,

M app/javascript/mastodon/components/status.jsx => app/javascript/mastodon/components/status.jsx +7 -5
@@ 4,7 4,7 @@ import PropTypes from 'prop-types';
import { Avatar } from './avatar';
import { AvatarOverlay } from './avatar_overlay';
import { RelativeTimestamp } from './relative_timestamp';
import DisplayName from './display_name';
import { DisplayName } from './display_name';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import AttachmentList from './attachment_list';


@@ 194,11 194,12 @@ class Status extends ImmutablePureComponent {

  handleOpenVideo = (options) => {
    const status = this._properStatus();
    this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
    this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), status.get('language'), options);
  };

  handleOpenMedia = (media, index) => {
    this.props.onOpenMedia(this._properStatus().get('id'), media, index);
    const status = this._properStatus();
    this.props.onOpenMedia(status.get('id'), media, index, status.get('language'));
  };

  handleHotkeyOpenMedia = e => {


@@ 208,10 209,11 @@ class Status extends ImmutablePureComponent {
    e.preventDefault();

    if (status.get('media_attachments').size > 0) {
      const lang = status.get('language');
      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
        onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 });
        onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 });
      } else {
        onOpenMedia(status.get('id'), status.get('media_attachments'), 0);
        onOpenMedia(status.get('id'), status.get('media_attachments'), 0, lang);
      }
    }
  };

M app/javascript/mastodon/components/status_list.jsx => app/javascript/mastodon/components/status_list.jsx +3 -1
@@ 26,6 26,7 @@ export default class StatusList extends ImmutablePureComponent {
    alwaysPrepend: PropTypes.bool,
    withCounters: PropTypes.bool,
    timelineId: PropTypes.string,
    lastId: PropTypes.string,
  };

  static defaultProps = {


@@ 55,7 56,8 @@ export default class StatusList extends ImmutablePureComponent {
  };

  handleLoadOlder = debounce(() => {
    this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
    const { statusIds, lastId, onLoadMore } = this.props;
    onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
  }, 300, { leading: true });

  _selectChild (index, align_top) {

M app/javascript/mastodon/components/verified_badge.tsx => app/javascript/mastodon/components/verified_badge.tsx +3 -2
@@ 1,9 1,10 @@
import React from 'react';

import { Icon } from './icon';

type Props = {
interface Props {
  link: string;
};
}
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
  <span className='verified-badge'>
    <Icon id='check' className='verified-badge__mark' />

M app/javascript/mastodon/containers/media_container.jsx => app/javascript/mastodon/containers/media_container.jsx +6 -4
@@ 29,19 29,20 @@ export default class MediaContainer extends PureComponent {
  state = {
    media: null,
    index: null,
    lang: null,
    time: null,
    backgroundColor: null,
    options: null,
  };

  handleOpenMedia = (media, index) => {
  handleOpenMedia = (media, index, lang) => {
    document.body.classList.add('with-modals--active');
    document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;

    this.setState({ media, index });
    this.setState({ media, index, lang });
  };

  handleOpenVideo = (options) => {
  handleOpenVideo = (lang, options) => {
    const { components } = this.props;
    const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
    const mediaList = fromJS(media);


@@ 49,7 50,7 @@ export default class MediaContainer extends PureComponent {
    document.body.classList.add('with-modals--active');
    document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;

    this.setState({ media: mediaList, options });
    this.setState({ media: mediaList, lang, options });
  };

  handleCloseMedia = () => {


@@ 105,6 106,7 @@ export default class MediaContainer extends PureComponent {
              <MediaModal
                media={this.state.media}
                index={this.state.index || 0}
                lang={this.state.lang}
                currentTime={this.state.options?.startTime}
                autoPlay={this.state.options?.autoPlay}
                volume={this.state.options?.defaultVolume}

M app/javascript/mastodon/containers/status_container.jsx => app/javascript/mastodon/containers/status_container.jsx +4 -4
@@ 182,12 182,12 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
    dispatch(mentionCompose(account, router));
  },

  onOpenMedia (statusId, media, index) {
    dispatch(openModal('MEDIA', { statusId, media, index }));
  onOpenMedia (statusId, media, index, lang) {
    dispatch(openModal('MEDIA', { statusId, media, index, lang }));
  },

  onOpenVideo (statusId, media, options) {
    dispatch(openModal('VIDEO', { statusId, media, options }));
  onOpenVideo (statusId, media, lang, options) {
    dispatch(openModal('VIDEO', { statusId, media, lang, options }));
  },

  onBlock (status) {

M app/javascript/mastodon/features/about/index.jsx => app/javascript/mastodon/features/about/index.jsx +2 -2
@@ 11,7 11,7 @@ import Account from 'mastodon/containers/account_container';
import Skeleton from 'mastodon/components/skeleton';
import { Icon }  from 'mastodon/components/icon';
import classNames from 'classnames';
import { Image } from 'mastodon/components/image';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';

const messages = defineMessages({
  title: { id: 'column.about', defaultMessage: 'About' },


@@ 114,7 114,7 @@ class About extends React.PureComponent {
      <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
        <div className='scrollable about'>
          <div className='about__header'>
            <Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
            <ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
            <h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
            <p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
          </div>

M app/javascript/mastodon/features/account/components/account_note.jsx => app/javascript/mastodon/features/account/components/account_note.jsx +3 -3
@@ 22,7 22,7 @@ class InlineAlert extends React.PureComponent {

  static TRANSITION_DELAY = 200;

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (!this.props.show && nextProps.show) {
      this.setState({ mountMessage: true });
    } else if (this.props.show && !nextProps.show) {


@@ 58,11 58,11 @@ class AccountNote extends ImmutablePureComponent {
    saved: false,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this._reset();
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    const accountWillChange = !is(this.props.account, nextProps.account);
    const newState = {};


M app/javascript/mastodon/features/account_gallery/index.jsx => app/javascript/mastodon/features/account_gallery/index.jsx +4 -3
@@ 136,16 136,17 @@ class AccountGallery extends ImmutablePureComponent {
  handleOpenMedia = attachment => {
    const { dispatch } = this.props;
    const statusId = attachment.getIn(['status', 'id']);
    const lang = attachment.getIn(['status', 'language']);

    if (attachment.get('type') === 'video') {
      dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } }));
      dispatch(openModal('VIDEO', { media: attachment, statusId, lang, options: { autoPlay: true } }));
    } else if (attachment.get('type') === 'audio') {
      dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } }));
      dispatch(openModal('AUDIO', { media: attachment, statusId, lang, options: { autoPlay: true } }));
    } else {
      const media = attachment.getIn(['status', 'media_attachments']);
      const index = media.findIndex(x => x.get('id') === attachment.get('id'));

      dispatch(openModal('MEDIA', { media, index, statusId }));
      dispatch(openModal('MEDIA', { media, index, statusId, lang }));
    }
  };


M app/javascript/mastodon/features/account_timeline/components/moved_note.jsx => app/javascript/mastodon/features/account_timeline/components/moved_note.jsx +1 -1
@@ 3,7 3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { AvatarOverlay } from '../../../components/avatar_overlay';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import { Link } from 'react-router-dom';

export default class MovedNote extends ImmutablePureComponent {

M app/javascript/mastodon/features/account_timeline/index.jsx => app/javascript/mastodon/features/account_timeline/index.jsx +1 -2
@@ 3,7 3,7 @@ import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator';
import Column from '../ui/components/column';


@@ 14,7 14,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import TimelineHint from 'mastodon/components/timeline_hint';
import { me } from 'mastodon/initial_state';
import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'mastodon/selectors';
import { fetchFeaturedTags } from '../../actions/featured_tags';

M app/javascript/mastodon/features/audio/index.jsx => app/javascript/mastodon/features/audio/index.jsx +1 -1
@@ 136,7 136,7 @@ class Audio extends React.PureComponent {
    }
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
      this.setState({ revealed: nextProps.visible });
    }

M app/javascript/mastodon/features/blocks/index.jsx => app/javascript/mastodon/features/blocks/index.jsx +1 -1
@@ 34,7 34,7 @@ class Blocks extends ImmutablePureComponent {
    multiColumn: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchBlocks());
  }


M app/javascript/mastodon/features/bookmarked_statuses/index.jsx => app/javascript/mastodon/features/bookmarked_statuses/index.jsx +1 -1
@@ 34,7 34,7 @@ class Bookmarks extends ImmutablePureComponent {
    isLoading: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchBookmarkedStatuses());
  }


M app/javascript/mastodon/features/compose/components/autosuggest_account.jsx => app/javascript/mastodon/features/compose/components/autosuggest_account.jsx +1 -1
@@ 1,6 1,6 @@
import React from 'react';
import { Avatar } from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';


M app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx => app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +1 -1
@@ 59,7 59,7 @@ class ModifierPickerMenu extends React.PureComponent {
    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
  };

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (nextProps.active) {
      this.attachListeners();
    } else {

M app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx => app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +1 -1
@@ 212,7 212,7 @@ class PrivacyDropdown extends React.PureComponent {
    this.props.onChange(value);
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    const { intl: { formatMessage } } = this.props;

    this.options = [

M app/javascript/mastodon/features/compose/components/reply_indicator.jsx => app/javascript/mastodon/features/compose/components/reply_indicator.jsx +1 -1
@@ 3,7 3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { Avatar } from '../../../components/avatar';
import { IconButton } from '../../../components/icon_button';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AttachmentList from 'mastodon/components/attachment_list';

M app/javascript/mastodon/features/directory/components/account_card.jsx => app/javascript/mastodon/features/directory/components/account_card.jsx +1 -1
@@ 5,7 5,7 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import { Avatar } from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import { DisplayName } from 'mastodon/components/display_name';
import { Link } from 'react-router-dom';
import Button from 'mastodon/components/button';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';

M app/javascript/mastodon/features/domain_blocks/index.jsx => app/javascript/mastodon/features/domain_blocks/index.jsx +1 -1
@@ 34,7 34,7 @@ class Blocks extends ImmutablePureComponent {
    multiColumn: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchDomainBlocks());
  }


M app/javascript/mastodon/features/favourited_statuses/index.jsx => app/javascript/mastodon/features/favourited_statuses/index.jsx +1 -1
@@ 34,7 34,7 @@ class Favourites extends ImmutablePureComponent {
    isLoading: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchFavouritedStatuses());
  }


M app/javascript/mastodon/features/favourites/index.jsx => app/javascript/mastodon/features/favourites/index.jsx +2 -2
@@ 31,13 31,13 @@ class Favourites extends ImmutablePureComponent {
    intl: PropTypes.object.isRequired,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    if (!this.props.accountIds) {
      this.props.dispatch(fetchFavourites(this.props.params.statusId));
    }
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
      this.props.dispatch(fetchFavourites(nextProps.params.statusId));
    }

M app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx => app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx +1 -1
@@ 3,7 3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Link } from 'react-router-dom';
import { Avatar } from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';

M app/javascript/mastodon/features/follow_requests/index.jsx => app/javascript/mastodon/features/follow_requests/index.jsx +1 -1
@@ 39,7 39,7 @@ class FollowRequests extends ImmutablePureComponent {
    multiColumn: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchFollowRequests());
  }


M app/javascript/mastodon/features/interaction_modal/index.jsx => app/javascript/mastodon/features/interaction_modal/index.jsx +1 -1
@@ 143,7 143,7 @@ class InteractionModal extends React.PureComponent {
        <div className='interaction-modal__choices'>
          <div className='interaction-modal__choices__choice'>
            <h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
            <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
            <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
            {signupButton}
          </div>


M app/javascript/mastodon/features/list_adder/components/account.jsx => app/javascript/mastodon/features/list_adder/components/account.jsx +1 -1
@@ 4,7 4,7 @@ import { makeGetAccount } from '../../../selectors';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Avatar } from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import { injectIntl } from 'react-intl';

const makeMapStateToProps = () => {

M app/javascript/mastodon/features/list_editor/components/account.jsx => app/javascript/mastodon/features/list_editor/components/account.jsx +1 -1
@@ 5,7 5,7 @@ import { makeGetAccount } from '../../../selectors';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Avatar } from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';

M app/javascript/mastodon/features/list_timeline/index.jsx => app/javascript/mastodon/features/list_timeline/index.jsx +1 -1
@@ 76,7 76,7 @@ class ListTimeline extends React.PureComponent {
    this.disconnect = dispatch(connectListStream(id));
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    const { dispatch } = this.props;
    const { id } = nextProps.params;


M app/javascript/mastodon/features/lists/index.jsx => app/javascript/mastodon/features/lists/index.jsx +1 -1
@@ 42,7 42,7 @@ class Lists extends ImmutablePureComponent {
    multiColumn: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchLists());
  }


M app/javascript/mastodon/features/mutes/index.jsx => app/javascript/mastodon/features/mutes/index.jsx +1 -1
@@ 35,7 35,7 @@ class Mutes extends ImmutablePureComponent {
    multiColumn: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchMutes());
  }


M app/javascript/mastodon/features/notifications/components/follow_request.jsx => app/javascript/mastodon/features/notifications/components/follow_request.jsx +1 -1
@@ 2,7 2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { Avatar } from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import { DisplayName } from 'mastodon/components/display_name';
import { Link } from 'react-router-dom';
import { IconButton } from 'mastodon/components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';

M app/javascript/mastodon/features/notifications/index.jsx => app/javascript/mastodon/features/notifications/index.jsx +1 -1
@@ 93,7 93,7 @@ class Notifications extends React.PureComponent {
    trackScroll: true,
  };

  componentWillMount() {
  UNSAFE_componentWillMount() {
    this.props.dispatch(mountNotifications());
  }


M app/javascript/mastodon/features/onboarding/follows.jsx => app/javascript/mastodon/features/onboarding/follows.jsx +5 -4
@@ 7,7 7,7 @@ import { fetchSuggestions } from 'mastodon/actions/suggestions';
import { markAsPartial } from 'mastodon/actions/timelines';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Account from 'mastodon/containers/account_container';
import EmptyAccount from 'mastodon/components/account';
import { EmptyAccount } from 'mastodon/components/empty_account';
import { FormattedMessage, FormattedHTMLMessage } from 'react-intl';
import { makeGetAccount } from 'mastodon/selectors';
import { me } from 'mastodon/initial_state';


@@ 31,6 31,7 @@ class Follows extends React.PureComponent {
    suggestions: ImmutablePropTypes.list,
    account: ImmutablePropTypes.map,
    isLoading: PropTypes.bool,
    multiColumn: PropTypes.bool,
  };

  componentDidMount () {


@@ 44,7 45,7 @@ class Follows extends React.PureComponent {
  }

  render () {
    const { onBack, isLoading, suggestions, account } = this.props;
    const { onBack, isLoading, suggestions, account, multiColumn } = this.props;

    let loadedContent;



@@ 58,7 59,7 @@ class Follows extends React.PureComponent {

    return (
      <Column>
        <ColumnBackButton onClick={onBack} />
        <ColumnBackButton multiColumn={multiColumn} onClick={onBack} />

        <div className='scrollable privacy-policy'>
          <div className='column-title'>


@@ 84,4 85,4 @@ class Follows extends React.PureComponent {

}

export default connect(mapStateToProps)(Follows);
\ No newline at end of file
export default connect(mapStateToProps)(Follows);

M app/javascript/mastodon/features/onboarding/index.jsx => app/javascript/mastodon/features/onboarding/index.jsx +5 -4
@@ 40,6 40,7 @@ class Onboarding extends ImmutablePureComponent {
  static propTypes = {
    dispatch: PropTypes.func.isRequired,
    account: ImmutablePropTypes.map,
    multiColumn: PropTypes.bool,
  };

  state = {


@@ 93,14 94,14 @@ class Onboarding extends ImmutablePureComponent {
  }

  render () {
    const { account } = this.props;
    const { account, multiColumn } = this.props;
    const { step, shareClicked } = this.state;

    switch(step) {
    case 'follows':
      return <Follows onBack={this.handleBackClick} />;
      return <Follows onBack={this.handleBackClick} multiColumn={multiColumn} />;
    case 'share':
      return <Share onBack={this.handleBackClick} />;
      return <Share onBack={this.handleBackClick} multiColumn={multiColumn} />;
    }

    return (


@@ 114,7 115,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. Lets 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='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.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/onboarding/share.jsx => app/javascript/mastodon/features/onboarding/share.jsx +3 -2
@@ 140,17 140,18 @@ class Share extends React.PureComponent {
  static propTypes = {
    onBack: PropTypes.func,
    account: ImmutablePropTypes.map,
    multiColumn: PropTypes.bool,
    intl: PropTypes.object,
  };

  render () {
    const { onBack, account, intl } = this.props;
    const { onBack, account, multiColumn, intl } = this.props;

    const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;

    return (
      <Column>
        <ColumnBackButton onClick={onBack} />
        <ColumnBackButton multiColumn={multiColumn} onClick={onBack} />

        <div className='scrollable privacy-policy'>
          <div className='column-title'>

M app/javascript/mastodon/features/picture_in_picture/components/header.jsx => app/javascript/mastodon/features/picture_in_picture/components/header.jsx +1 -1
@@ 6,7 6,7 @@ import PropTypes from 'prop-types';
import { IconButton } from 'mastodon/components/icon_button';
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import { DisplayName } from 'mastodon/components/display_name';
import { defineMessages, injectIntl } from 'react-intl';

const messages = defineMessages({

M app/javascript/mastodon/features/pinned_statuses/index.jsx => app/javascript/mastodon/features/pinned_statuses/index.jsx +1 -1
@@ 29,7 29,7 @@ class PinnedStatuses extends ImmutablePureComponent {
    multiColumn: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchPinnedStatuses());
  }


M app/javascript/mastodon/features/reblogs/index.jsx => app/javascript/mastodon/features/reblogs/index.jsx +2 -2
@@ 31,13 31,13 @@ class Reblogs extends ImmutablePureComponent {
    intl: PropTypes.object.isRequired,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    if (!this.props.accountIds) {
      this.props.dispatch(fetchReblogs(this.props.params.statusId));
    }
  }

  componentWillReceiveProps(nextProps) {
  UNSAFE_componentWillReceiveProps(nextProps) {
    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
      this.props.dispatch(fetchReblogs(nextProps.params.statusId));
    }

M app/javascript/mastodon/features/report/components/status_check_box.jsx => app/javascript/mastodon/features/report/components/status_check_box.jsx +1 -1
@@ 3,7 3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusContent from 'mastodon/components/status_content';
import { Avatar } from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import { DisplayName } from 'mastodon/components/display_name';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import Option from './option';
import MediaAttachments from 'mastodon/components/media_attachments';

M app/javascript/mastodon/features/status/components/card.jsx => app/javascript/mastodon/features/status/components/card.jsx +1 -1
@@ 66,7 66,7 @@ export default class Card extends React.PureComponent {
    revealed: !this.props.sensitive,
  };

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (!Immutable.is(this.props.card, nextProps.card)) {
      this.setState({ embedded: false, previewLoaded: false });
    }

M app/javascript/mastodon/features/status/components/detailed_status.jsx => app/javascript/mastodon/features/status/components/detailed_status.jsx +1 -1
@@ 2,7 2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Avatar } from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom';

M app/javascript/mastodon/features/status/containers/detailed_status_container.js => app/javascript/mastodon/features/status/containers/detailed_status_container.js +4 -4
@@ 128,12 128,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
    dispatch(mentionCompose(account, router));
  },

  onOpenMedia (media, index) {
    dispatch(openModal('MEDIA', { media, index }));
  onOpenMedia (media, index, lang) {
    dispatch(openModal('MEDIA', { media, index, lang }));
  },

  onOpenVideo (media, options) {
    dispatch(openModal('VIDEO', { media, options }));
  onOpenVideo (media, lang, options) {
    dispatch(openModal('VIDEO', { media, lang, options }));
  },

  onBlock (status) {

M app/javascript/mastodon/features/status/index.jsx => app/javascript/mastodon/features/status/index.jsx +6 -6
@@ 207,7 207,7 @@ class Status extends ImmutablePureComponent {
    loadedStatusId: undefined,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchStatus(this.props.params.statusId));
  }



@@ 215,7 215,7 @@ class Status extends ImmutablePureComponent {
    attachFullscreenListener(this.onFullScreenChange);
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
      this._scrolledIntoView = false;
      this.props.dispatch(fetchStatus(nextProps.params.statusId));


@@ 345,12 345,12 @@ class Status extends ImmutablePureComponent {
    this.props.dispatch(mentionCompose(account, router));
  };

  handleOpenMedia = (media, index) => {
    this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index }));
  handleOpenMedia = (media, index, lang) => {
    this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index, lang }));
  };

  handleOpenVideo = (media, options) => {
    this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
  handleOpenVideo = (media, lang, options) => {
    this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, lang, options }));
  };

  handleHotkeyOpenMedia = e => {

M app/javascript/mastodon/features/ui/components/boost_modal.jsx => app/javascript/mastodon/features/ui/components/boost_modal.jsx +1 -1
@@ 7,7 7,7 @@ import Button from '../../../components/button';
import StatusContent from '../../../components/status_content';
import { Avatar } from '../../../components/avatar';
import { RelativeTimestamp } from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name';
import { DisplayName } from '../../../components/display_name';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Icon }  from 'mastodon/components/icon';
import AttachmentList from 'mastodon/components/attachment_list';

M app/javascript/mastodon/features/ui/components/bundle.jsx => app/javascript/mastodon/features/ui/components/bundle.jsx +2 -2
@@ 33,11 33,11 @@ class Bundle extends React.PureComponent {
    forceRender: false,
  };

  componentWillMount() {
  UNSAFE_componentWillMount() {
    this.load(this.props);
  }

  componentWillReceiveProps(nextProps) {
  UNSAFE_componentWillReceiveProps(nextProps) {
    if (nextProps.fetchComponent !== this.props.fetchComponent) {
      this.load(nextProps);
    }

M app/javascript/mastodon/features/ui/components/columns_area.jsx => app/javascript/mastodon/features/ui/components/columns_area.jsx +2 -2
@@ 18,7 18,7 @@ import {
  BookmarkedStatuses,
  ListTimeline,
  Directory,
} from '../../ui/util/async-components';
} from '../util/async-components';
import ComposePanel from './compose_panel';
import NavigationPanel from './navigation_panel';
import { supportsPassiveEvents } from 'detect-passive-events';


@@ 76,7 76,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
    this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
  }

  componentWillUpdate(nextProps) {
  UNSAFE_componentWillUpdate(nextProps) {
    if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
      this.node.removeEventListener('wheel', this.handleWheel);
    }

M app/javascript/mastodon/features/ui/components/focal_point_modal.jsx => app/javascript/mastodon/features/ui/components/focal_point_modal.jsx +1 -2
@@ 5,11 5,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose';
import { getPointerPosition } from '../../video';
import Video, { getPointerPosition } from '../../video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { IconButton } from 'mastodon/components/icon_button';
import Button from 'mastodon/components/button';
import Video from 'mastodon/features/video';
import Audio from 'mastodon/features/audio';
import Textarea from 'react-textarea-autosize';
import UploadProgress from 'mastodon/features/compose/components/upload_progress';

M app/javascript/mastodon/features/ui/components/header.jsx => app/javascript/mastodon/features/ui/components/header.jsx +3 -3
@@ 51,13 51,13 @@ class Header extends React.PureComponent {

      if (registrationsOpen) {
        signupButton = (
          <a href='/auth/sign_up' className='button button-tertiary'>
          <a href='/auth/sign_up' className='button'>
            <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
          </a>
        );
      } else {
        signupButton = (
          <button className='button button-tertiary' onClick={openClosedRegistrationsModal}>
          <button className='button' onClick={openClosedRegistrationsModal}>
            <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
          </button>
        );


@@ 65,8 65,8 @@ class Header extends React.PureComponent {

      content = (
        <>
          <a href='/auth/sign_in' className='button'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
          {signupButton}
          <a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
        </>
      );
    }

M app/javascript/mastodon/features/ui/components/media_modal.jsx => app/javascript/mastodon/features/ui/components/media_modal.jsx +6 -10
@@ 3,7 3,6 @@ import ReactSwipeableViews from 'react-swipeable-views';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Video from 'mastodon/features/video';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
import { IconButton } from 'mastodon/components/icon_button';


@@ 21,15 20,12 @@ const messages = defineMessages({
  next: { id: 'lightbox.next', defaultMessage: 'Next' },
});

const mapStateToProps = (state, { statusId }) => ({
  language: state.getIn(['statuses', statusId, 'language']),
});

class MediaModal extends ImmutablePureComponent {

  static propTypes = {
    media: ImmutablePropTypes.list.isRequired,
    statusId: PropTypes.string,
    lang: PropTypes.string,
    index: PropTypes.number.isRequired,
    onClose: PropTypes.func.isRequired,
    intl: PropTypes.object.isRequired,


@@ 133,7 129,7 @@ class MediaModal extends ImmutablePureComponent {
  };

  render () {
    const { media, language, statusId, intl, onClose } = this.props;
    const { media, statusId, lang, intl, onClose } = this.props;
    const { navigationHidden } = this.state;

    const index = this.getIndex();


@@ 153,7 149,7 @@ class MediaModal extends ImmutablePureComponent {
            width={width}
            height={height}
            alt={image.get('description')}
            lang={language}
            lang={lang}
            key={image.get('url')}
            onClick={this.toggleNavigation}
            zoomButtonHidden={this.state.zoomButtonHidden}


@@ 176,7 172,7 @@ class MediaModal extends ImmutablePureComponent {
            onCloseVideo={onClose}
            detailed
            alt={image.get('description')}
            lang={language}
            lang={lang}
            key={image.get('url')}
          />
        );


@@ 188,7 184,7 @@ class MediaModal extends ImmutablePureComponent {
            height={height}
            key={image.get('url')}
            alt={image.get('description')}
            lang={language}
            lang={lang}
            onClick={this.toggleNavigation}
          />
        );


@@ 256,4 252,4 @@ class MediaModal extends ImmutablePureComponent {

}

export default connect(mapStateToProps, null, null, { forwardRef: true })(injectIntl(MediaModal));
export default injectIntl(MediaModal);

M app/javascript/mastodon/features/ui/components/navigation_panel.jsx => app/javascript/mastodon/features/ui/components/navigation_panel.jsx +2 -2
@@ 2,7 2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import Logo from 'mastodon/components/logo';
import { WordmarkLogo } from 'mastodon/components/logo';
import { timelinePreview, showTrends } from 'mastodon/initial_state';
import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner';


@@ 46,7 46,7 @@ class NavigationPanel extends React.Component {
    return (
      <div className='navigation-panel'>
        <div className='navigation-panel__logo'>
          <Link to='/' className='column-link column-link--logo'><Logo /></Link>
          <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
          <hr />
        </div>


M app/javascript/mastodon/features/ui/components/sign_in_banner.jsx => app/javascript/mastodon/features/ui/components/sign_in_banner.jsx +4 -4
@@ 16,13 16,13 @@ const SignInBanner = () => {

  if (registrationsOpen) {
    signupButton = (
      <a href='/auth/sign_up' className='button button--block button-tertiary'>
      <a href='/auth/sign_up' className='button button--block'>
        <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
      </a>
    );
  } else {
    signupButton = (
      <button className='button button--block button-tertiary' onClick={openClosedRegistrationsModal}>
      <button className='button button--block' onClick={openClosedRegistrationsModal}>
        <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
      </button>
    );


@@ 30,9 30,9 @@ const SignInBanner = () => {

  return (
    <div className='sign-in-banner'>
      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
      <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Login to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
      {signupButton}
      <a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
    </div>
  );
};

M app/javascript/mastodon/features/ui/components/upload_area.jsx => app/javascript/mastodon/features/ui/components/upload_area.jsx +1 -1
@@ 1,6 1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import Motion from '../../ui/util/optional_motion';
import Motion from '../util/optional_motion';
import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl';


M app/javascript/mastodon/features/ui/containers/status_list_container.js => app/javascript/mastodon/features/ui/containers/status_list_container.js +1 -0
@@ 37,6 37,7 @@ const makeMapStateToProps = () => {

  const mapStateToProps = (state, { timelineId }) => ({
    statusIds: getStatusIds(state, { type: timelineId }),
    lastId:    state.getIn(['timelines', timelineId, 'items'])?.last(),
    isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
    isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
    hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),

M app/javascript/mastodon/features/ui/index.jsx => app/javascript/mastodon/features/ui/index.jsx +1 -1
@@ 123,7 123,7 @@ class SwitchingColumnsArea extends React.PureComponent {
    mobile: PropTypes.bool,
  };

  componentWillMount () {
  UNSAFE_componentWillMount () {
    if (this.props.mobile) {
      document.body.classList.toggle('layout-single-column', true);
      document.body.classList.toggle('layout-multiple-columns', false);

M app/javascript/mastodon/features/video/index.jsx => app/javascript/mastodon/features/video/index.jsx +2 -2
@@ 370,7 370,7 @@ class Video extends React.PureComponent {
    }
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
      this.setState({ revealed: nextProps.visible });
    }


@@ 469,7 469,7 @@ class Video extends React.PureComponent {
  handleOpenVideo = () => {
    this.video.pause();

    this.props.onOpenVideo({
    this.props.onOpenVideo(this.props.lang, {
      startTime: this.video.currentTime,
      autoPlay: !this.state.paused,
      defaultVolume: this.state.volume,

M app/javascript/mastodon/is_mobile.ts => app/javascript/mastodon/is_mobile.ts +1 -0
@@ 1,4 1,5 @@
import { supportsPassiveEvents } from 'detect-passive-events';

import { forceSingleColumn } from './initial_state';

const LAYOUT_BREAKPOINT = 630;

M app/javascript/mastodon/locales/defaultMessages.json => app/javascript/mastodon/locales/defaultMessages.json +6 -6
@@ 356,7 356,7 @@
  {
    "descriptors": [
      {
        "defaultMessage": "You need to sign in to access this resource.",
        "defaultMessage": "You need to login to access this resource.",
        "id": "not_signed_in_indicator.not_signed_in"
      }
    ],


@@ 2623,7 2623,7 @@
        "id": "interaction_modal.on_this_server"
      },
      {
        "defaultMessage": "Sign in",
        "defaultMessage": "Login",
        "id": "sign_in_banner.sign_in"
      },
      {


@@ 3236,7 3236,7 @@
        "id": "onboarding.steps.follow_people.title"
      },
      {
        "defaultMessage": "You curate your own feed. Lets fill it with interesting people.",
        "defaultMessage": "You curate your own feed. Let's fill it with interesting people.",
        "id": "onboarding.steps.follow_people.body"
      },
      {


@@ 4175,7 4175,7 @@
        "id": "sign_in_banner.create_account"
      },
      {
        "defaultMessage": "Sign in",
        "defaultMessage": "Login",
        "id": "sign_in_banner.sign_in"
      }
    ],


@@ 4374,11 4374,11 @@
        "id": "sign_in_banner.create_account"
      },
      {
        "defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
        "defaultMessage": "Login to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
        "id": "sign_in_banner.text"
      },
      {
        "defaultMessage": "Sign in",
        "defaultMessage": "Login",
        "id": "sign_in_banner.sign_in"
      }
    ],

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +3 -3
@@ 391,7 391,7 @@
  "navigation_bar.public_timeline": "Federated timeline",
  "navigation_bar.search": "Search",
  "navigation_bar.security": "Security",
  "not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
  "not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
  "notification.admin.report": "{name} reported {target}",
  "notification.admin.sign_up": "{name} signed up",
  "notification.favourite": "{name} favourited your post",


@@ 573,8 573,8 @@
  "server_banner.learn_more": "Learn more",
  "server_banner.server_stats": "Server stats:",
  "sign_in_banner.create_account": "Create account",
  "sign_in_banner.sign_in": "Sign in",
  "sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
  "sign_in_banner.sign_in": "Login",
  "sign_in_banner.text": "Login to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
  "status.admin_account": "Open moderation interface for @{name}",
  "status.admin_domain": "Open moderation interface for {domain}",
  "status.admin_status": "Open this post in the moderation interface",

M app/javascript/mastodon/locales/locale-data/co.js => app/javascript/mastodon/locales/locale-data/co.js +3 -1
@@ 2,7 2,7 @@
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/

export default [{
const rules = [{
  locale: "co",
  pluralRuleFunction: function (e, a) {
    return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";


@@ 106,3 106,5 @@ export default [{
    },
  },
}];

export default rules;

M app/javascript/mastodon/locales/locale-data/oc.js => app/javascript/mastodon/locales/locale-data/oc.js +3 -1
@@ 2,7 2,7 @@
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/

export default [{
const rules = [{
  locale: "oc",
  pluralRuleFunction: function (e, a) {
    return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";


@@ 106,3 106,5 @@ export default [{
    },
  },
}];

export default rules;

M app/javascript/mastodon/locales/locale-data/sa.js => app/javascript/mastodon/locales/locale-data/sa.js +4 -3
@@ 2,9 2,8 @@
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/
/*eslint comma-dangle: "off"*/
/*eslint semi: "off"*/

export default [
const rules = [
  {
    locale: "sa",
    fields: {


@@ 94,4 93,6 @@ export default [
      }
    }
  }
]
];

export default rules;

M app/javascript/mastodon/polyfills/base_polyfills.ts => app/javascript/mastodon/polyfills/base_polyfills.ts +7 -2
@@ 10,8 10,13 @@ if (!HTMLCanvasElement.prototype.toBlob) {
  const BASE64_MARKER = ';base64,';

  Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
    value(callback: BlobCallback, type = 'image/png', quality: any) {
      const dataURL = this.toDataURL(type, quality);
    value: function (
      this: HTMLCanvasElement,
      callback: BlobCallback,
      type = 'image/png',
      quality: unknown
    ) {
      const dataURL: string = this.toDataURL(type, quality);
      let data;

      if (dataURL.indexOf(BASE64_MARKER) >= 0) {

M app/javascript/mastodon/reducers/index.ts => app/javascript/mastodon/reducers/index.ts +33 -32
@@ 1,46 1,47 @@
import { combineReducers } from 'redux-immutable';
import dropdown_menu from './dropdown_menu';
import timelines from './timelines';
import meta from './meta';
import alerts from './alerts';
import { loadingBarReducer } from 'react-redux-loading-bar';
import modal from './modal';
import user_lists from './user_lists';
import domain_lists from './domain_lists';
import { combineReducers } from 'redux-immutable';

import accounts from './accounts';
import accounts_counters from './accounts_counters';
import statuses from './statuses';
import relationships from './relationships';
import settings from './settings';
import push_notifications from './push_notifications';
import status_lists from './status_lists';
import mutes from './mutes';
import accounts_map from './accounts_map';
import alerts from './alerts';
import announcements from './announcements';
import blocks from './blocks';
import boosts from './boosts';
import server from './server';
import contexts from './contexts';
import compose from './compose';
import search from './search';
import media_attachments from './media_attachments';
import notifications from './notifications';
import height_cache from './height_cache';
import contexts from './contexts';
import conversations from './conversations';
import custom_emojis from './custom_emojis';
import lists from './lists';
import listEditor from './list_editor';
import listAdder from './list_adder';
import domain_lists from './domain_lists';
import dropdown_menu from './dropdown_menu';
import filters from './filters';
import conversations from './conversations';
import suggestions from './suggestions';
import polls from './polls';
import trends from './trends';
import { missedUpdatesReducer } from './missed_updates';
import announcements from './announcements';
import followed_tags from './followed_tags';
import height_cache from './height_cache';
import history from './history';
import listAdder from './list_adder';
import listEditor from './list_editor';
import lists from './lists';
import markers from './markers';
import media_attachments from './media_attachments';
import meta from './meta';
import { missedUpdatesReducer } from './missed_updates';
import modal from './modal';
import mutes from './mutes';
import notifications from './notifications';
import picture_in_picture from './picture_in_picture';
import accounts_map from './accounts_map';
import history from './history';
import polls from './polls';
import push_notifications from './push_notifications';
import relationships from './relationships';
import search from './search';
import server from './server';
import settings from './settings';
import status_lists from './status_lists';
import statuses from './statuses';
import suggestions from './suggestions';
import tags from './tags';
import followed_tags from './followed_tags';
import timelines from './timelines';
import trends from './trends';
import user_lists from './user_lists';

const reducers = {
  announcements,

M app/javascript/mastodon/reducers/markers.js => app/javascript/mastodon/reducers/markers.js +2 -2
@@ 2,13 2,13 @@ import {
  MARKERS_SUBMIT_SUCCESS,
} from '../actions/markers';

import { Map as ImmutableMap } from 'immutable';

const initialState = ImmutableMap({
  home: '0',
  notifications: '0',
});

import { Map as ImmutableMap } from 'immutable';

export default function markers(state = initialState, action) {
  switch(action.type) {
  case MARKERS_SUBMIT_SUCCESS:

M app/javascript/mastodon/reducers/missed_updates.ts => app/javascript/mastodon/reducers/missed_updates.ts +5 -3
@@ 1,12 1,14 @@
import { Record } from 'immutable';

import type { Action } from 'redux';
import { NOTIFICATIONS_UPDATE } from '../actions/notifications';

import { focusApp, unfocusApp } from '../actions/app';
import { NOTIFICATIONS_UPDATE } from '../actions/notifications';

type MissedUpdatesState = {
interface MissedUpdatesState {
  focused: boolean;
  unread: number;
};
}
const initialState = Record<MissedUpdatesState>({
  focused: true,
  unread: 0,

M app/javascript/mastodon/store/index.ts => app/javascript/mastodon/store/index.ts +21 -3
@@ 1,14 1,32 @@
import type { TypedUseSelectorHook } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';

import { configureStore } from '@reduxjs/toolkit';

import { rootReducer } from '../reducers';
import { loadingBarMiddleware } from './middlewares/loading_bar';

import { errorsMiddleware } from './middlewares/errors';
import { loadingBarMiddleware } from './middlewares/loading_bar';
import { soundsMiddleware } from './middlewares/sounds';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';

export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
    getDefaultMiddleware({
      // In development, Redux Toolkit enables 2 default middlewares to detect
      // common issues with states. Unfortunately, our use of ImmutableJS for state
      // triggers both, so lets disable them until our state is fully refactored

      // https://redux-toolkit.js.org/api/serializabilityMiddleware
      // This checks recursively that every values in the state are serializable in JSON
      // Which is not the case, as we use ImmutableJS structures, but also File objects
      serializableCheck: false,

      // https://redux-toolkit.js.org/api/immutabilityMiddleware
      // This checks recursively if every value in the state is immutable (ie, a JS primitive type)
      // But this is not the case, as our Root State is an ImmutableJS map, which is an object
      immutableCheck: false,
    })
      .concat(
        loadingBarMiddleware({
          promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],

M app/javascript/mastodon/store/middlewares/errors.ts => app/javascript/mastodon/store/middlewares/errors.ts +5 -4
@@ 1,17 1,18 @@
import { Middleware } from 'redux';
import type { AnyAction, Middleware } from 'redux';

import type { RootState } from '..';
import { showAlertForError } from '../../actions/alerts';
import { RootState } from '..';

const defaultFailSuffix = 'FAIL';

export const errorsMiddleware: Middleware<Record<string, never>, RootState> =
  ({ dispatch }) =>
  (next) =>
  (action) => {
  (action: AnyAction & { skipAlert?: boolean; skipNotFound?: boolean }) => {
    if (action.type && !action.skipAlert) {
      const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');

      if (action.type.match(isFail)) {
      if (typeof action.type === 'string' && action.type.match(isFail)) {
        dispatch(showAlertForError(action.error, action.skipNotFound));
      }
    }

M app/javascript/mastodon/store/middlewares/loading_bar.ts => app/javascript/mastodon/store/middlewares/loading_bar.ts +13 -10
@@ 1,6 1,7 @@
import { showLoading, hideLoading } from 'react-redux-loading-bar';
import { Middleware } from 'redux';
import { RootState } from '..';
import type { AnyAction, Middleware } from 'redux';

import type { RootState } from '..';

interface Config {
  promiseTypeSuffixes?: string[];


@@ 19,7 20,7 @@ export const loadingBarMiddleware = (

  return ({ dispatch }) =>
    (next) =>
    (action) => {
    (action: AnyAction) => {
      if (action.type && !action.skipLoading) {
        const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;



@@ 27,13 28,15 @@ export const loadingBarMiddleware = (
        const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
        const isRejected = new RegExp(`${REJECTED}$`, 'g');

        if (action.type.match(isPending)) {
          dispatch(showLoading());
        } else if (
          action.type.match(isFulfilled) ||
          action.type.match(isRejected)
        ) {
          dispatch(hideLoading());
        if (typeof action.type === 'string') {
          if (action.type.match(isPending)) {
            dispatch(showLoading());
          } else if (
            action.type.match(isFulfilled) ||
            action.type.match(isRejected)
          ) {
            dispatch(hideLoading());
          }
        }
      }


M app/javascript/mastodon/store/middlewares/sounds.ts => app/javascript/mastodon/store/middlewares/sounds.ts +13 -10
@@ 1,5 1,6 @@
import { Middleware, AnyAction } from 'redux';
import { RootState } from '..';
import type { Middleware, AnyAction } from 'redux';

import type { RootState } from '..';

interface AudioSource {
  src: string;


@@ 27,7 28,7 @@ const play = (audio: HTMLAudioElement) => {
    }
  }

  audio.play();
  void audio.play();
};

export const soundsMiddleware = (): Middleware<


@@ 47,13 48,15 @@ export const soundsMiddleware = (): Middleware<
    ]),
  };

  return () => (next) => (action: AnyAction) => {
    const sound = action?.meta?.sound;
  return () =>
    (next) =>
    (action: AnyAction & { meta?: { sound?: string } }) => {
      const sound = action?.meta?.sound;

    if (sound && soundCache[sound]) {
      play(soundCache[sound]);
    }
      if (sound && soundCache[sound]) {
        play(soundCache[sound]);
      }

    return next(action);
  };
      return next(action);
    };
};

M app/javascript/mastodon/uuid.ts => app/javascript/mastodon/uuid.ts +4 -3
@@ 1,8 1,9 @@
export function uuid(a?: string): string {
  return a
    ? (
        (a as any as number) ^
        ((Math.random() * 16) >> ((a as any as number) / 4))
        (a as unknown as number) ^
        ((Math.random() * 16) >> ((a as unknown as number) / 4))
      ).toString(16)
    : ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
    : // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
      ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
}

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +3 -15
@@ 3118,7 3118,7 @@ $ui-header-height: 55px;

  &.active {
    transition: none;
    box-shadow: 0 0 0 2px rgba(lighten($highlight-text-color, 8%), 0.7);
    box-shadow: 0 0 0 6px rgba(lighten($highlight-text-color, 8%), 0.7);
  }
}



@@ 6447,13 6447,6 @@ a.status-card.compact:hover {
  &--wide {
    grid-column: span 2;
  }

  &.standalone {
    .media-gallery__item-gifv-thumbnail {
      transform: none;
      top: 0;
    }
  }
}

.media-gallery__item-thumbnail {


@@ 6501,11 6494,7 @@ a.status-card.compact:hover {
  cursor: zoom-in;
  height: 100%;
  object-fit: cover;
  position: relative;
  top: 50%;
  transform: translateY(-50%);
  width: 100%;
  z-index: 1;
}

.media-gallery__item-thumbnail-label {


@@ 6604,6 6593,8 @@ a.status-card.compact:hover {
  border-radius: 4px;
  box-sizing: border-box;
  color: $white;
  display: flex;
  align-items: center;

  &.editable {
    border-radius: 0;


@@ 6638,9 6629,6 @@ a.status-card.compact:hover {
  &.inline {
    video {
      object-fit: contain;
      position: relative;
      top: 50%;
      transform: translateY(-50%);
    }
  }


M app/javascript/types/resources.ts => app/javascript/types/resources.ts +4 -4
@@ 12,7 12,7 @@ type AccountField = Record<{
  verified_at: string | null;
}>;

type AccountApiResponseValues = {
interface AccountApiResponseValues {
  acct: string;
  avatar: string;
  avatar_static: string;


@@ 34,7 34,7 @@ type AccountApiResponseValues = {
  statuses_count: number;
  url: string;
  username: string;
};
}

type NormalizedAccountField = Record<{
  name_emojified: string;


@@ 42,12 42,12 @@ type NormalizedAccountField = Record<{
  value_plain: string;
}>;

type NormalizedAccountValues = {
interface NormalizedAccountValues {
  display_name_html: string;
  fields: NormalizedAccountField[];
  note_emojified: string;
  note_plain: string;
};
}

export type Account = Record<
  AccountApiResponseValues & NormalizedAccountValues

M app/lib/account_reach_finder.rb => app/lib/account_reach_finder.rb +8 -1
@@ 6,7 6,7 @@ class AccountReachFinder
  end

  def inboxes
    (followers_inboxes + reporters_inboxes + relay_inboxes).uniq
    (followers_inboxes + reporters_inboxes + recently_mentioned_inboxes + relay_inboxes).uniq
  end

  private


@@ 19,6 19,13 @@ class AccountReachFinder
    Account.where(id: @account.targeted_reports.select(:account_id)).inboxes
  end

  def recently_mentioned_inboxes
    cutoff_id       = Mastodon::Snowflake.id_at(2.days.ago, with_random: false)
    recent_statuses = @account.statuses.recent.where(id: cutoff_id...).limit(200)

    Account.joins(:mentions).where(mentions: { status: recent_statuses }).inboxes.take(2000)
  end

  def relay_inboxes
    Relay.enabled.pluck(:inbox_url)
  end

M app/lib/vacuum/access_tokens_vacuum.rb => app/lib/vacuum/access_tokens_vacuum.rb +4 -2
@@ 9,10 9,12 @@ class Vacuum::AccessTokensVacuum
  private

  def vacuum_revoked_access_tokens!
    Doorkeeper::AccessToken.where.not(revoked_at: nil).where('revoked_at < NOW()').delete_all
    Doorkeeper::AccessToken.where.not(expires_in: nil).where('created_at + make_interval(secs => expires_in) < NOW()').in_batches.delete_all
    Doorkeeper::AccessToken.where.not(revoked_at: nil).where('revoked_at < NOW()').in_batches.delete_all
  end

  def vacuum_revoked_access_grants!
    Doorkeeper::AccessGrant.where.not(revoked_at: nil).where('revoked_at < NOW()').delete_all
    Doorkeeper::AccessGrant.where.not(expires_in: nil).where('created_at + make_interval(secs => expires_in) < NOW()').in_batches.delete_all
    Doorkeeper::AccessGrant.where.not(revoked_at: nil).where('revoked_at < NOW()').in_batches.delete_all
  end
end

M app/models/form/account_batch.rb => app/models/form/account_batch.rb +11 -0
@@ 123,7 123,18 @@ class Form::AccountBatch
      account: current_account,
      action: :suspend
    )

    Admin::SuspensionWorker.perform_async(account.id)

    # Suspending a single account closes their associated reports, so
    # mass-suspending would be consistent.
    Report.where(target_account: account).unresolved.find_each do |report|
      authorize(report, :update?)
      log_action(:resolve, report)
      report.resolve!(current_account)
    rescue Mastodon::NotPermittedError
      # This should not happen, but just in case, do not fail early
    end
  end

  def approve_account(account)

M app/views/admin/reports/_media_attachments.html.haml => app/views/admin/reports/_media_attachments.html.haml +3 -3
@@ 1,8 1,8 @@
- if status.ordered_media_attachments.first.video?
  - video = status.ordered_media_attachments.first
  = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: status.sensitive?, visible: false, width: 610, height: 343, inline: true, alt: video.description, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json
  = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: status.sensitive?, visible: false, width: 610, height: 343, inline: true, alt: video.description, lang: status.language, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json
- elsif status.ordered_media_attachments.first.audio?
  - audio = status.ordered_media_attachments.first
  = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration)
  = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, lang: status.language, duration: audio.file.meta.dig(:original, :duration)
- else
  = react_component :media_gallery, height: 343, sensitive: status.sensitive?, visible: false, media: status.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
  = react_component :media_gallery, height: 343, sensitive: status.sensitive?, visible: false, lang: status.language, media: status.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }

M config/initializers/rack_attack.rb => config/initializers/rack_attack.rb +1 -1
@@ 145,7 145,7 @@ class Rack::Attack
      'Content-Type'          => 'application/json',
      'X-RateLimit-Limit'     => match_data[:limit].to_s,
      'X-RateLimit-Remaining' => '0',
      'X-RateLimit-Reset'     => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6),
      'X-RateLimit-Reset'     => (now + (match_data[:period] - (now.to_i % match_data[:period]))).iso8601(6),
    }

    [429, headers, [{ error: I18n.t('errors.429') }.to_json]]

M config/locales/devise.en.yml => config/locales/devise.en.yml +3 -3
@@ 13,8 13,8 @@ en:
      locked: Your account is locked.
      not_found_in_database: Invalid %{authentication_keys} or password.
      pending: Your account is still under review.
      timeout: Your session expired. Please sign in again to continue.
      unauthenticated: You need to sign in or sign up before continuing.
      timeout: Your session expired. Please login again to continue.
      unauthenticated: You need to login or sign up before continuing.
      unconfirmed: You have to confirm your email address before continuing.
    mailer:
      confirmation_instructions:


@@ 102,7 102,7 @@ en:
    unlocks:
      send_instructions: You will receive an email with instructions for how to unlock your account in a few minutes. Please check your spam folder if you didn't receive this email.
      send_paranoid_instructions: If your account exists, you will receive an email with instructions for how to unlock it in a few minutes. Please check your spam folder if you didn't receive this email.
      unlocked: Your account has been unlocked successfully. Please sign in to continue.
      unlocked: Your account has been unlocked successfully. Please login to continue.
  errors:
    messages:
      already_confirmed: was already confirmed, please try signing in

M config/locales/en.yml => config/locales/en.yml +3 -3
@@ 1027,8 1027,8 @@ en:
      new_confirmation_instructions_sent: You will receive a new e-mail with the confirmation link in a few minutes!
      title: Check your inbox
    sign_in:
      preamble_html: Sign in with your <strong>%{domain}</strong> credentials. If your account is hosted on a different server, you will not be able to log in here.
      title: Sign in to %{domain}
      preamble_html: Login with your <strong>%{domain}</strong> credentials. If your account is hosted on a different server, you will not be able to log in here.
      title: Login to %{domain}
    sign_up:
      manual_review: Sign-ups on %{domain} go through manual review by our moderators. To help us process your registration, write a bit about yourself and why you want an account on %{domain}.
      preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted.


@@ 1595,7 1595,7 @@ en:
    show_newer: Show newer
    show_older: Show older
    show_thread: Show thread
    sign_in_to_participate: Sign in to participate in the conversation
    sign_in_to_participate: Login to participate in the conversation
    title: '%{name}: "%{quote}"'
    visibilities:
      direct: Direct

M config/webpack/generateLocalePacks.js => config/webpack/generateLocalePacks.js +1 -1
@@ 12,7 12,7 @@
const { existsSync, readdirSync, writeFileSync } = require('fs');
const { join, resolve } = require('path');
const rimraf = require('rimraf');
const mkdirp = require('mkdirp');
const { mkdirp } = require('mkdirp');
const { flavours } = require('./configuration');

module.exports = Object.keys(flavours).reduce(function (map, entry) {

M package.json => package.json +7 -6
@@ 81,11 81,11 @@
    "mark-loader": "^0.1.6",
    "marky": "^1.2.5",
    "mini-css-extract-plugin": "^1.6.2",
    "mkdirp": "^2.1.6",
    "mkdirp": "^3.0.1",
    "npmlog": "^7.0.1",
    "path-complete-extname": "^1.0.0",
    "pg": "^8.5.0",
    "pg-connection-string": "^2.5.0",
    "pg-connection-string": "^2.6.0",
    "postcss": "^8.4.23",
    "postcss-loader": "^4.3.0",
    "prop-types": "^15.8.1",


@@ 179,14 179,15 @@
    "@types/uuid": "^9.0.0",
    "@types/webpack": "^4.41.33",
    "@types/yargs": "^17.0.24",
    "@typescript-eslint/eslint-plugin": "^5.59.5",
    "@typescript-eslint/parser": "^5.59.5",
    "@typescript-eslint/eslint-plugin": "^5.59.6",
    "@typescript-eslint/parser": "^5.59.6",
    "babel-jest": "^29.5.0",
    "eslint": "^8.39.0",
    "eslint": "^8.40.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-import-resolver-typescript": "^3.5.5",
    "eslint-plugin-formatjs": "^4.10.1",
    "eslint-plugin-import": "~2.27.5",
    "eslint-plugin-jsdoc": "^43.1.1",
    "eslint-plugin-jsdoc": "^44.2.4",
    "eslint-plugin-jsx-a11y": "~6.7.1",
    "eslint-plugin-prettier": "^4.2.1",
    "eslint-plugin-promise": "~6.1.1",

M spec/controllers/activitypub/followers_synchronizations_controller_spec.rb => spec/controllers/activitypub/followers_synchronizations_controller_spec.rb +0 -2
@@ 14,9 14,7 @@ RSpec.describe ActivityPub::FollowersSynchronizationsController do
    follower_2.follow!(account)
    follower_3.follow!(account)
    follower_4.follow!(account)
  end

  before do
    allow(controller).to receive(:signed_request_actor).and_return(remote_account)
  end


M spec/controllers/activitypub/outboxes_controller_spec.rb => spec/controllers/activitypub/outboxes_controller_spec.rb +0 -2
@@ 27,9 27,7 @@ RSpec.describe ActivityPub::OutboxesController do
    Fabricate(:status, account: account, visibility: :private)
    Fabricate(:status, account: account, visibility: :direct)
    Fabricate(:status, account: account, visibility: :limited)
  end

  before do
    allow(controller).to receive(:signed_request_actor).and_return(remote_account)
  end


M spec/controllers/admin/disputes/appeals_controller_spec.rb => spec/controllers/admin/disputes/appeals_controller_spec.rb +5 -5
@@ 5,16 5,16 @@ require 'rails_helper'
RSpec.describe Admin::Disputes::AppealsController do
  render_views

  before { sign_in current_user, scope: :user }
  before do
    sign_in current_user, scope: :user

    target_account.suspend!
  end

  let(:target_account) { Fabricate(:account) }
  let(:strike) { Fabricate(:account_warning, target_account: target_account, action: :suspend) }
  let(:appeal) { Fabricate(:appeal, strike: strike, account: target_account) }

  before do
    target_account.suspend!
  end

  describe 'POST #approve' do
    let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }


M spec/controllers/admin/reports/actions_controller_spec.rb => spec/controllers/admin/reports/actions_controller_spec.rb +2 -2
@@ 146,13 146,13 @@ describe Admin::Reports::ActionsController do
      end
    end

    context 'with Action as submit button' do
    context 'with action as submit button' do
      subject { post :create, params: common_params.merge({ action => '' }) }

      it_behaves_like 'all action types'
    end

    context 'with Action as submit button' do
    context 'with moderation action as an extra field' do
      subject { post :create, params: common_params.merge({ moderation_action: action }) }

      it_behaves_like 'all action types'

M spec/controllers/api/v1/admin/canonical_email_blocks_controller_spec.rb => spec/controllers/api/v1/admin/canonical_email_blocks_controller_spec.rb +48 -0
@@ 20,4 20,52 @@ describe Api::V1::Admin::CanonicalEmailBlocksController do
      expect(response).to have_http_status(200)
    end
  end

  describe 'POST #test' do
    context 'when required email is not provided' do
      it 'returns http bad request' do
        post :test

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

    context 'when required email is provided' do
      let(:params) { { email: 'example@email.com' } }

      context 'when there is a matching canonical email block' do
        let!(:canonical_email_block) { CanonicalEmailBlock.create(params) }

        it 'returns http success' do
          post :test, params: params

          expect(response).to have_http_status(200)
        end

        it 'returns expected canonical email hash' do
          post :test, params: params

          json = body_as_json

          expect(json[0][:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash)
        end
      end

      context 'when there is no matching canonical email block' do
        it 'returns http success' do
          post :test, params: params

          expect(response).to have_http_status(200)
        end

        it 'returns an empty list' do
          post :test, params: params

          json = body_as_json

          expect(json).to be_empty
        end
      end
    end
  end
end

M spec/controllers/auth/registrations_controller_spec.rb => spec/controllers/auth/registrations_controller_spec.rb +3 -3
@@ 97,10 97,12 @@ RSpec.describe Auth::RegistrationsController do
  end

  describe 'POST #create' do
    let(:accept_language) { Rails.application.config.i18n.available_locales.sample.to_s }
    let(:accept_language) { 'de' }

    before do
      session[:registration_form_time] = 5.seconds.ago

      request.env['devise.mapping'] = Devise.mappings[:user]
    end

    around do |example|


@@ 109,8 111,6 @@ RSpec.describe Auth::RegistrationsController do
      end
    end

    before { request.env['devise.mapping'] = Devise.mappings[:user] }

    context do
      subject do
        Setting.registrations_mode = 'open'

M spec/fabricators/notification_fabricator.rb => spec/fabricators/notification_fabricator.rb +1 -1
@@ 1,6 1,6 @@
# frozen_string_literal: true

Fabricator(:notification) do
  activity fabricator: [:mention, :status, :follow, :follow_request, :favourite].sample
  activity fabricator: :status
  account
end

A spec/lib/account_reach_finder_spec.rb => spec/lib/account_reach_finder_spec.rb +53 -0
@@ 0,0 1,53 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe AccountReachFinder do
  let(:account) { Fabricate(:account) }

  let(:follower1) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-1') }
  let(:follower2) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-2') }
  let(:follower3) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/users/a/inbox', shared_inbox_url: 'https://foo.bar/inbox') }

  let(:mentioned1) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/users/b/inbox', shared_inbox_url: 'https://foo.bar/inbox') }
  let(:mentioned2) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-3') }
  let(:mentioned3) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-4') }

  let(:unrelated_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/unrelated-inbox') }

  before do
    follower1.follow!(account)
    follower2.follow!(account)
    follower3.follow!(account)

    Fabricate(:status, account: account).tap do |status|
      status.mentions << Mention.new(account: follower1)
      status.mentions << Mention.new(account: mentioned1)
    end

    Fabricate(:status, account: account)

    Fabricate(:status, account: account).tap do |status|
      status.mentions << Mention.new(account: mentioned2)
      status.mentions << Mention.new(account: mentioned3)
    end

    Fabricate(:status).tap do |status|
      status.mentions << Mention.new(account: unrelated_account)
    end
  end

  describe '#inboxes' do
    it 'includes the preferred inbox URL of followers' do
      expect(described_class.new(account).inboxes).to include(*[follower1, follower2, follower3].map(&:preferred_inbox_url))
    end

    it 'includes the preferred inbox URL of recently-mentioned accounts' do
      expect(described_class.new(account).inboxes).to include(*[mentioned1, mentioned2, mentioned3].map(&:preferred_inbox_url))
    end

    it 'does not include the inbox of unrelated users' do
      expect(described_class.new(account).inboxes).to_not include(unrelated_account.preferred_inbox_url)
    end
  end
end

A spec/lib/mastodon/ip_blocks_cli_spec.rb => spec/lib/mastodon/ip_blocks_cli_spec.rb +292 -0
@@ 0,0 1,292 @@
# frozen_string_literal: true

require 'rails_helper'
require 'mastodon/ip_blocks_cli'

RSpec.describe Mastodon::IpBlocksCLI do
  let(:cli) { described_class.new }

  describe '#add' do
    let(:ip_list) do
      [
        '192.0.2.1',
        '172.16.0.1',
        '192.0.2.0/24',
        '172.16.0.0/16',
        '10.0.0.0/8',
        '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
        'fe80::1',
        '::1',
        '2001:0db8::/32',
        'fe80::/10',
        '::/128',
      ]
    end
    let(:options) { { severity: 'no_access' } }

    shared_examples 'ip address blocking' do
      it 'blocks all specified IP addresses' do
        cli.invoke(:add, ip_list, options)

        blocked_ip_addresses = IpBlock.where(ip: ip_list).pluck(:ip)
        expected_ip_addresses = ip_list.map { |ip| IPAddr.new(ip) }

        expect(blocked_ip_addresses).to match_array(expected_ip_addresses)
      end

      it 'sets the severity for all blocked IP addresses' do
        cli.invoke(:add, ip_list, options)

        blocked_ips_severity = IpBlock.where(ip: ip_list).pluck(:severity).all?(options[:severity])

        expect(blocked_ips_severity).to be(true)
      end

      it 'displays a success message with a summary' do
        expect { cli.invoke(:add, ip_list, options) }.to output(
          a_string_including("Added #{ip_list.size}, skipped 0, failed 0")
        ).to_stdout
      end
    end

    context 'with valid IP addresses' do
      include_examples 'ip address blocking'
    end

    context 'when a specified IP address is already blocked' do
      let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: options[:severity]) }

      it 'skips the already blocked IP address' do
        allow(IpBlock).to receive(:new).and_call_original

        cli.invoke(:add, ip_list, options)

        expect(IpBlock).to_not have_received(:new).with(ip: ip_list.last)
      end

      it 'displays the correct summary' do
        expect { cli.invoke(:add, ip_list, options) }.to output(
          a_string_including("#{ip_list.last} is already blocked\nAdded #{ip_list.size - 1}, skipped 1, failed 0")
        ).to_stdout
      end

      context 'with --force option' do
        let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: 'no_access') }
        let(:options) { { severity: 'sign_up_requires_approval', force: true } }

        it 'overwrites the existing IP block record' do
          expect { cli.invoke(:add, ip_list, options) }
            .to change { blocked_ip.reload.severity }
            .from('no_access')
            .to('sign_up_requires_approval')
        end

        include_examples 'ip address blocking'
      end
    end

    context 'when a specified IP address is invalid' do
      let(:ip_list) { ['320.15.175.0', '9.5.105.255', '0.0.0.0'] }

      it 'displays the correct summary' do
        expect { cli.invoke(:add, ip_list, options) }.to output(
          a_string_including("#{ip_list.first} is invalid\nAdded #{ip_list.size - 1}, skipped 0, failed 1")
        ).to_stdout
      end
    end

    context 'with --comment option' do
      let(:options) { { severity: 'no_access', comment: 'Spam' } }

      include_examples 'ip address blocking'
    end

    context 'with --duration option' do
      let(:options) { { severity: 'no_access', duration: 10.days } }

      include_examples 'ip address blocking'
    end

    context 'with "sign_up_requires_approval" severity' do
      let(:options) { { severity: 'sign_up_requires_approval' } }

      include_examples 'ip address blocking'
    end

    context 'with "sign_up_block" severity' do
      let(:options) { { severity: 'sign_up_block' } }

      include_examples 'ip address blocking'
    end

    context 'when a specified IP address fails to be blocked' do
      let(:ip_address) { '127.0.0.1' }
      let(:ip_block) { instance_double(IpBlock, ip: ip_address, save: false) }

      before do
        allow(IpBlock).to receive(:new).and_return(ip_block)
        allow(ip_block).to receive(:severity=)
        allow(ip_block).to receive(:expires_in=)
      end

      it 'displays an error message' do
        expect { cli.invoke(:add, [ip_address], options) }
          .to output(
            a_string_including("#{ip_address} could not be saved")
          ).to_stdout
      end
    end

    context 'when no IP address is provided' do
      it 'exits with an error message' do
        expect { cli.add }.to output(
          a_string_including('No IP(s) given')
        ).to_stdout
          .and raise_error(SystemExit)
      end
    end
  end

  describe '#remove' do
    context 'when removing exact matches' do
      let(:ip_list) do
        [
          '192.0.2.1',
          '172.16.0.1',
          '192.0.2.0/24',
          '172.16.0.0/16',
          '10.0.0.0/8',
          '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
          'fe80::1',
          '::1',
          '2001:0db8::/32',
          'fe80::/10',
          '::/128',
        ]
      end

      before do
        ip_list.each { |ip| IpBlock.create(ip: ip, severity: :no_access) }
      end

      it 'removes exact IP blocks' do
        cli.invoke(:remove, ip_list)

        expect(IpBlock.where(ip: ip_list)).to_not exist
      end

      it 'displays success message with a summary' do
        expect { cli.invoke(:remove, ip_list) }.to output(
          a_string_including("Removed #{ip_list.size}, skipped 0")
        ).to_stdout
      end
    end

    context 'with --force option' do
      let!(:block1) { IpBlock.create(ip: '192.168.0.0/24', severity: :no_access) }
      let!(:block2) { IpBlock.create(ip: '10.0.0.0/16', severity: :no_access) }
      let!(:block3) { IpBlock.create(ip: '172.16.0.0/20', severity: :no_access) }
      let(:arguments) { ['192.168.0.5', '10.0.1.50'] }
      let(:options) { { force: true } }

      it 'removes blocks for IP ranges that cover given IP(s)' do
        cli.invoke(:remove, arguments, options)

        expect(IpBlock.where(id: [block1.id, block2.id])).to_not exist
      end

      it 'does not remove other IP ranges' do
        cli.invoke(:remove, arguments, options)

        expect(IpBlock.where(id: block3.id)).to exist
      end
    end

    context 'when a specified IP address is not blocked' do
      let(:unblocked_ip) { '192.0.2.1' }

      it 'skips the IP address' do
        expect { cli.invoke(:remove, [unblocked_ip]) }.to output(
          a_string_including("#{unblocked_ip} is not yet blocked")
        ).to_stdout
      end

      it 'displays the summary correctly' do
        expect { cli.invoke(:remove, [unblocked_ip]) }.to output(
          a_string_including('Removed 0, skipped 1')
        ).to_stdout
      end
    end

    context 'when a specified IP address is invalid' do
      let(:invalid_ip) { '320.15.175.0' }

      it 'skips the invalid IP address' do
        expect { cli.invoke(:remove, [invalid_ip]) }.to output(
          a_string_including("#{invalid_ip} is invalid")
        ).to_stdout
      end

      it 'displays the summary correctly' do
        expect { cli.invoke(:remove, [invalid_ip]) }.to output(
          a_string_including('Removed 0, skipped 1')
        ).to_stdout
      end
    end

    context 'when no IP address is provided' do
      it 'exits with an error message' do
        expect { cli.remove }.to output(
          a_string_including('No IP(s) given')
        ).to_stdout
          .and raise_error(SystemExit)
      end
    end
  end

  describe '#export' do
    let(:block1) { IpBlock.create(ip: '192.168.0.0/24', severity: :no_access) }
    let(:block2) { IpBlock.create(ip: '10.0.0.0/16', severity: :no_access) }
    let(:block3) { IpBlock.create(ip: '127.0.0.1', severity: :sign_up_block) }

    context 'when --format option is set to "plain"' do
      let(:options) { { format: 'plain' } }

      it 'exports blocked IPs with "no_access" severity in plain format' do
        expect { cli.invoke(:export, nil, options) }.to output(
          a_string_including("#{block1.ip}/#{block1.ip.prefix}\n#{block2.ip}/#{block2.ip.prefix}")
        ).to_stdout
      end

      it 'does not export bloked IPs with different severities' do
        expect { cli.invoke(:export, nil, options) }.to_not output(
          a_string_including("#{block3.ip}/#{block1.ip.prefix}")
        ).to_stdout
      end
    end

    context 'when --format option is set to "nginx"' do
      let(:options) { { format: 'nginx' } }

      it 'exports blocked IPs with "no_access" severity in plain format' do
        expect { cli.invoke(:export, nil, options) }.to output(
          a_string_including("deny #{block1.ip}/#{block1.ip.prefix};\ndeny #{block2.ip}/#{block2.ip.prefix};")
        ).to_stdout
      end

      it 'does not export bloked IPs with different severities' do
        expect { cli.invoke(:export, nil, options) }.to_not output(
          a_string_including("deny #{block3.ip}/#{block1.ip.prefix};")
        ).to_stdout
      end
    end

    context 'when --format option is not provided' do
      it 'exports blocked IPs in plain format by default' do
        expect { cli.export }.to output(
          a_string_including("#{block1.ip}/#{block1.ip.prefix}\n#{block2.ip}/#{block2.ip.prefix}")
        ).to_stdout
      end
    end
  end
end

M spec/lib/vacuum/access_tokens_vacuum_spec.rb => spec/lib/vacuum/access_tokens_vacuum_spec.rb +10 -0
@@ 7,9 7,11 @@ RSpec.describe Vacuum::AccessTokensVacuum do

  describe '#perform' do
    let!(:revoked_access_token) { Fabricate(:access_token, revoked_at: 1.minute.ago) }
    let!(:expired_access_token) { Fabricate(:access_token, expires_in: 59.minutes.to_i, created_at: 1.hour.ago) }
    let!(:active_access_token) { Fabricate(:access_token) }

    let!(:revoked_access_grant) { Fabricate(:access_grant, revoked_at: 1.minute.ago) }
    let!(:expired_access_grant) { Fabricate(:access_grant, expires_in: 59.minutes.to_i, created_at: 1.hour.ago) }
    let!(:active_access_grant) { Fabricate(:access_grant) }

    before do


@@ 20,10 22,18 @@ RSpec.describe Vacuum::AccessTokensVacuum do
      expect { revoked_access_token.reload }.to raise_error ActiveRecord::RecordNotFound
    end

    it 'deletes expired access tokens' do
      expect { expired_access_token.reload }.to raise_error ActiveRecord::RecordNotFound
    end

    it 'deletes revoked access grants' do
      expect { revoked_access_grant.reload }.to raise_error ActiveRecord::RecordNotFound
    end

    it 'deletes expired access grants' do
      expect { expired_access_grant.reload }.to raise_error ActiveRecord::RecordNotFound
    end

    it 'does not delete active access tokens' do
      expect { active_access_token.reload }.to_not raise_error
    end

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

require 'rails_helper'

describe 'I18n' do
  describe 'Pluralizing locale translations' do
    subject { I18n.t('generic.validation_errors', count: 1) }

    context 'with the `en` locale which has `one` and `other` plural values' do
      around do |example|
        I18n.with_locale(:en) do
          example.run
        end
      end

      it 'translates to `en` correctly and without error' do
        expect { subject }.to_not raise_error
        expect(subject).to match(/the error below/)
      end
    end

    context 'with the `my` locale which has only `other` plural value' do
      around do |example|
        I18n.with_locale(:my) do
          example.run
        end
      end

      it 'translates to `my` correctly and without error' do
        expect { subject }.to_not raise_error
        expect(subject).to match(/1/)
      end
    end
  end
end

M spec/mailers/notification_mailer_spec.rb => spec/mailers/notification_mailer_spec.rb +1 -1
@@ 10,7 10,7 @@ RSpec.describe NotificationMailer do

  shared_examples 'localized subject' do |*args, **kwrest|
    it 'renders subject localized for the locale of the receiver' do
      locale = %i(de en).sample
      locale = :de
      receiver.update!(locale: locale)
      expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale))
    end

M spec/mailers/user_mailer_spec.rb => spec/mailers/user_mailer_spec.rb +1 -1
@@ 7,7 7,7 @@ describe UserMailer do

  shared_examples 'localized subject' do |*args, **kwrest|
    it 'renders subject localized for the locale of the receiver' do
      locale = I18n.available_locales.sample
      locale = :de
      receiver.update!(locale: locale)
      expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale))
    end

A spec/models/form/account_batch_spec.rb => spec/models/form/account_batch_spec.rb +63 -0
@@ 0,0 1,63 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Form::AccountBatch do
  let(:account_batch) { described_class.new }

  describe '#save' do
    subject           { account_batch.save }

    let(:account)     { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
    let(:account_ids) { [] }
    let(:query)       { Account.none }

    before do
      account_batch.assign_attributes(
        action: action,
        current_account: account,
        account_ids: account_ids,
        query: query,
        select_all_matching: select_all_matching
      )
    end

    context 'when action is "suspend"' do
      let(:action) { 'suspend' }

      let(:target_account)  { Fabricate(:account) }
      let(:target_account2) { Fabricate(:account) }

      before do
        Fabricate(:report, target_account: target_account)
        Fabricate(:report, target_account: target_account2)
      end

      context 'when accounts are passed as account_ids' do
        let(:select_all_matching) { '0' }
        let(:account_ids)         { [target_account.id, target_account2.id] }

        it 'suspends the expected users' do
          expect { subject }.to change { [target_account.reload.suspended?, target_account2.reload.suspended?] }.from([false, false]).to([true, true])
        end

        it 'closes open reports targeting the suspended users' do
          expect { subject }.to change { Report.unresolved.where(target_account: [target_account, target_account2]).count }.from(2).to(0)
        end
      end

      context 'when accounts are passed as a query' do
        let(:select_all_matching) { '1' }
        let(:query)               { Account.where(id: [target_account.id, target_account2.id]) }

        it 'suspends the expected users' do
          expect { subject }.to change { [target_account.reload.suspended?, target_account2.reload.suspended?] }.from([false, false]).to([true, true])
        end

        it 'closes open reports targeting the suspended users' do
          expect { subject }.to change { Report.unresolved.where(target_account: [target_account, target_account2]).count }.from(2).to(0)
        end
      end
    end
  end
end

M spec/policies/report_note_policy_spec.rb => spec/policies/report_note_policy_spec.rb +9 -11
@@ 30,19 30,17 @@ RSpec.describe ReportNotePolicy do
      end
    end

    context 'when admin?' do
      context 'when owner?' do
        it 'permit' do
          report_note = Fabricate(:report_note, account: john)
          expect(subject).to permit(john, report_note)
        end
    context 'when owner?' do
      it 'permit' do
        report_note = Fabricate(:report_note, account: john)
        expect(subject).to permit(john, report_note)
      end
    end

      context 'with !owner?' do
        it 'denies' do
          report_note = Fabricate(:report_note)
          expect(subject).to_not permit(john, report_note)
        end
    context 'with !owner?' do
      it 'denies' do
        report_note = Fabricate(:report_note)
        expect(subject).to_not permit(john, report_note)
      end
    end
  end

M spec/services/activitypub/process_account_service_spec.rb => spec/services/activitypub/process_account_service_spec.rb +2 -4
@@ 139,10 139,6 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
  end

  context 'when Accounts referencing other accounts' do
    before do
      stub_const 'ActivityPub::ProcessAccountService::DISCOVERIES_PER_REQUEST', 5
    end

    let(:payload) do
      {
        '@context': ['https://www.w3.org/ns/activitystreams'],


@@ 155,6 151,8 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
    end

    before do
      stub_const 'ActivityPub::ProcessAccountService::DISCOVERIES_PER_REQUEST', 5

      8.times do |i|
        actor_json = {
          '@context': ['https://www.w3.org/ns/activitystreams'],

M spec/services/unsuspend_account_service_spec.rb => spec/services/unsuspend_account_service_spec.rb +3 -3
@@ 3,7 3,7 @@
require 'rails_helper'

RSpec.describe UnsuspendAccountService, type: :service do
  shared_examples 'common behavior' do
  shared_context 'with common context' do
    subject { described_class.new.call(account) }

    let!(:local_follower) { Fabricate(:user, current_sign_in_at: 1.hour.ago).account }


@@ 36,7 36,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
      expect { subject }.to_not change { account.suspended? }
    end

    include_examples 'common behavior' do
    include_examples 'with common context' do
      let!(:account)         { Fabricate(:account) }
      let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub) }
      let!(:remote_reporter) { Fabricate(:account, uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }


@@ 61,7 61,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
  end

  describe 'unsuspending a remote account' do
    include_examples 'common behavior' do
    include_examples 'with common context' do
      let!(:account)                 { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
      let!(:resolve_account_service) { double }


M yarn.lock => yarn.lock +265 -104
@@ 1224,10 1224,10 @@
  resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46"
  integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==

"@es-joy/jsdoccomment@~0.37.1":
  version "0.37.1"
  resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.37.1.tgz#fa32a41ba12097452693343e09ad4d26d157aedd"
  integrity sha512-5vxWJ1gEkEF0yRd0O+uK6dHJf7adrxwQSX8PuRiPfFSAbNLnY0ZJfXaZucoz14Jj2N11xn2DnlEPwWRpYpvRjg==
"@es-joy/jsdoccomment@~0.39.3":
  version "0.39.3"
  resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.39.3.tgz#76b55203bf447d608e4e299ecb62d7ef14db72bb"
  integrity sha512-q6pObzaS+aTA96kl4DF91QILNpSiDE8S89cQdJnhIc7hWzwIHPnfBnsiBVa0Z/R9pLHdZTnXEMnggGMmCq7HmA==
  dependencies:
    comment-parser "1.3.1"
    esquery "^1.5.0"


@@ 1245,14 1245,14 @@
  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.4.0.tgz#3e61c564fcd6b921cb789838631c5ee44df09403"
  integrity sha512-A9983Q0LnDGdLPjxyXQ00sbV+K+O+ko2Dr+CZigbHWtX9pNfxlaBkMR8X1CztI73zuEyEBXTVjx7CE+/VSwDiQ==

"@eslint/eslintrc@^2.0.2":
  version "2.0.2"
  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.2.tgz#01575e38707add677cf73ca1589abba8da899a02"
  integrity sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==
"@eslint/eslintrc@^2.0.3":
  version "2.0.3"
  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.3.tgz#4910db5505f4d503f27774bf356e3704818a0331"
  integrity sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==
  dependencies:
    ajv "^6.12.4"
    debug "^4.3.2"
    espree "^9.5.1"
    espree "^9.5.2"
    globals "^13.19.0"
    ignore "^5.2.0"
    import-fresh "^3.2.1"


@@ 1260,10 1260,10 @@
    minimatch "^3.1.2"
    strip-json-comments "^3.1.1"

"@eslint/js@8.39.0":
  version "8.39.0"
  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.39.0.tgz#58b536bcc843f4cd1e02a7e6171da5c040f4d44b"
  integrity sha512-kf9RB0Fg7NZfap83B3QOqOGg9QmD9yBudqQXzzOtn3i4y7ZUXe5ONeW34Gwi+TxhH4mvj72R1Zc300KUMa9Bng==
"@eslint/js@8.40.0":
  version "8.40.0"
  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.40.0.tgz#3ba73359e11f5a7bd3e407f70b3528abfae69cec"
  integrity sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==

"@floating-ui/core@^1.0.1":
  version "1.0.1"


@@ 1678,6 1678,18 @@
  resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
  integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==

"@pkgr/utils@^2.3.1":
  version "2.4.0"
  resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.4.0.tgz#b6373d2504aedaf2fc7cdf2d13ab1f48fa5f12d5"
  integrity sha512-2OCURAmRtdlL8iUDTypMrrxfwe8frXTeXaxGsVOaYtc/wrUyk8Z/0OBetM7cdlsy7ZFWlMX72VogKeh+A4Xcjw==
  dependencies:
    cross-spawn "^7.0.3"
    fast-glob "^3.2.12"
    is-glob "^4.0.3"
    open "^9.1.0"
    picocolors "^1.0.0"
    tslib "^2.5.0"

"@polka/url@^1.0.0-next.9":
  version "1.0.0-next.11"
  resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.11.tgz#aeb16f50649a91af79dbe36574b66d0f9e4d9f71"


@@ 2475,15 2487,15 @@
  dependencies:
    "@types/yargs-parser" "*"

"@typescript-eslint/eslint-plugin@^5.59.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz#f156827610a3f8cefc56baeaa93cd4a5f32966b4"
  integrity sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==
"@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==
  dependencies:
    "@eslint-community/regexpp" "^4.4.0"
    "@typescript-eslint/scope-manager" "5.59.5"
    "@typescript-eslint/type-utils" "5.59.5"
    "@typescript-eslint/utils" "5.59.5"
    "@typescript-eslint/scope-manager" "5.59.6"
    "@typescript-eslint/type-utils" "5.59.6"
    "@typescript-eslint/utils" "5.59.6"
    debug "^4.3.4"
    grapheme-splitter "^1.0.4"
    ignore "^5.2.0"


@@ 2491,31 2503,31 @@
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/parser@^5.59.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.5.tgz#63064f5eafbdbfb5f9dfbf5c4503cdf949852981"
  integrity sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==
"@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==
  dependencies:
    "@typescript-eslint/scope-manager" "5.59.5"
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/typescript-estree" "5.59.5"
    "@typescript-eslint/scope-manager" "5.59.6"
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/typescript-estree" "5.59.6"
    debug "^4.3.4"

"@typescript-eslint/scope-manager@5.59.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz#33ffc7e8663f42cfaac873de65ebf65d2bce674d"
  integrity sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==
"@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==
  dependencies:
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/visitor-keys" "5.59.5"
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/visitor-keys" "5.59.6"

"@typescript-eslint/type-utils@5.59.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz#485b0e2c5b923460bc2ea6b338c595343f06fc9b"
  integrity sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==
"@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==
  dependencies:
    "@typescript-eslint/typescript-estree" "5.59.5"
    "@typescript-eslint/utils" "5.59.5"
    "@typescript-eslint/typescript-estree" "5.59.6"
    "@typescript-eslint/utils" "5.59.6"
    debug "^4.3.4"
    tsutils "^3.21.0"



@@ 2524,10 2536,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.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.5.tgz#e63c5952532306d97c6ea432cee0981f6d2258c7"
  integrity sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==
"@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/typescript-estree@5.59.0":
  version "5.59.0"


@@ 2542,30 2554,30 @@
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/typescript-estree@5.59.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz#9b252ce55dd765e972a7a2f99233c439c5101e42"
  integrity sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==
"@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==
  dependencies:
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/visitor-keys" "5.59.5"
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/visitor-keys" "5.59.6"
    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.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.5.tgz#15b3eb619bb223302e60413adb0accd29c32bcae"
  integrity sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==
"@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==
  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.5"
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/typescript-estree" "5.59.5"
    "@typescript-eslint/scope-manager" "5.59.6"
    "@typescript-eslint/types" "5.59.6"
    "@typescript-eslint/typescript-estree" "5.59.6"
    eslint-scope "^5.1.1"
    semver "^7.3.7"



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

"@typescript-eslint/visitor-keys@5.59.5":
  version "5.59.5"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz#ba5b8d6791a13cf9fea6716af1e7626434b29b9b"
  integrity sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==
"@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==
  dependencies:
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/types" "5.59.6"
    eslint-visitor-keys "^3.3.0"

"@webassemblyjs/ast@1.9.0":


@@ 3379,6 3391,11 @@ batch@0.6.1:
  resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
  integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=

big-integer@^1.6.44:
  version "1.6.51"
  resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
  integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==

big.js@^5.2.2:
  version "5.2.2"
  resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"


@@ 3461,6 3478,13 @@ boolbase@^1.0.0:
  resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
  integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=

bplist-parser@^0.2.0:
  version "0.2.0"
  resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.2.0.tgz#43a9d183e5bf9d545200ceac3e712f79ebbe8d0e"
  integrity sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==
  dependencies:
    big-integer "^1.6.44"

brace-expansion@^1.1.7:
  version "1.1.11"
  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"


@@ 3636,6 3660,13 @@ builtin-status-codes@^3.0.0:
  resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
  integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=

bundle-name@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-3.0.0.tgz#ba59bcc9ac785fb67ccdbf104a2bf60c099f0e1a"
  integrity sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==
  dependencies:
    run-applescript "^5.0.0"

bytes@3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"


@@ 4536,6 4567,24 @@ deepmerge@^4.0, deepmerge@^4.2.2:
  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==

default-browser-id@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-3.0.0.tgz#bee7bbbef1f4e75d31f98f4d3f1556a14cea790c"
  integrity sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==
  dependencies:
    bplist-parser "^0.2.0"
    untildify "^4.0.0"

default-browser@^4.0.0:
  version "4.0.0"
  resolved "https://registry.yarnpkg.com/default-browser/-/default-browser-4.0.0.tgz#53c9894f8810bf86696de117a6ce9085a3cbc7da"
  integrity sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==
  dependencies:
    bundle-name "^3.0.0"
    default-browser-id "^3.0.0"
    execa "^7.1.1"
    titleize "^3.0.0"

default-gateway@^4.2.0:
  version "4.2.0"
  resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b"


@@ 4544,6 4593,11 @@ default-gateway@^4.2.0:
    execa "^1.0.0"
    ip-regex "^2.1.0"

define-lazy-prop@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f"
  integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==

define-properties@^1.1.3, define-properties@^1.1.4:
  version "1.1.4"
  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1"


@@ 4882,6 4936,14 @@ enhanced-resolve@^4.1.1, enhanced-resolve@^4.5.0:
    memory-fs "^0.5.0"
    tapable "^1.0.0"

enhanced-resolve@^5.12.0:
  version "5.13.0"
  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.13.0.tgz#26d1ecc448c02de997133217b5c1053f34a0a275"
  integrity sha512-eyV8f0y1+bzyfh8xAwW/WTSZpLbjhqc4ne9eGSH4Zo2ejdyiNG9pU6mf9DG8a7+Auk6MFTlNOT4Y2y/9k8GKVg==
  dependencies:
    graceful-fs "^4.2.4"
    tapable "^2.2.0"

entities@^4.2.0, entities@^4.4.0:
  version "4.4.0"
  resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174"


@@ 5020,6 5082,20 @@ eslint-import-resolver-node@^0.3.7:
    is-core-module "^2.11.0"
    resolve "^1.22.1"

eslint-import-resolver-typescript@^3.5.5:
  version "3.5.5"
  resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.5.tgz#0a9034ae7ed94b254a360fbea89187b60ea7456d"
  integrity sha512-TdJqPHs2lW5J9Zpe17DZNQuDnox4xo2o+0tE7Pggain9Rbc19ik8kFtXdxZ250FVx2kF4vlt2RSf4qlUpG7bhw==
  dependencies:
    debug "^4.3.4"
    enhanced-resolve "^5.12.0"
    eslint-module-utils "^2.7.4"
    get-tsconfig "^4.5.0"
    globby "^13.1.3"
    is-core-module "^2.11.0"
    is-glob "^4.0.3"
    synckit "^0.8.5"

eslint-module-utils@^2.7.4:
  version "2.7.4"
  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974"


@@ 5065,18 5141,18 @@ eslint-plugin-import@~2.27.5:
    semver "^6.3.0"
    tsconfig-paths "^3.14.1"

eslint-plugin-jsdoc@^43.1.1:
  version "43.1.1"
  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-43.1.1.tgz#fc72ba21597cc99b1a0dc988aebb9bb57d0ec492"
  integrity sha512-J2kjjsJ5vBXSyNzqJhceeSGTAgVgZHcPSJKo3vD4tNjUdfky98rR2VfZUDsS1GKL6isyVa8GWvr+Az7Vyg2HXA==
eslint-plugin-jsdoc@^44.2.4:
  version "44.2.4"
  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-44.2.4.tgz#0bdc163771504ec7330414eda6a7dbae67156ddb"
  integrity sha512-/EMMxCyRh1SywhCb66gAqoGX4Yv6Xzc4bsSkF1AiY2o2+bQmGMQ05QZ5+JjHbdFTPDZY9pfn+DsSNP0a5yQpIg==
  dependencies:
    "@es-joy/jsdoccomment" "~0.37.1"
    "@es-joy/jsdoccomment" "~0.39.3"
    are-docs-informative "^0.0.2"
    comment-parser "1.3.1"
    debug "^4.3.4"
    escape-string-regexp "^4.0.0"
    esquery "^1.5.0"
    semver "^7.5.0"
    semver "^7.5.1"
    spdx-expression-parse "^3.0.1"

eslint-plugin-jsx-a11y@~6.7.1:


@@ 5163,20 5239,20 @@ eslint-scope@^7.2.0:
    esrecurse "^4.3.0"
    estraverse "^5.2.0"

eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.0:
  version "3.4.0"
  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz#c7f0f956124ce677047ddbc192a68f999454dedc"
  integrity sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==
eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1:
  version "3.4.1"
  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994"
  integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==

eslint@^8.39.0:
  version "8.39.0"
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.39.0.tgz#7fd20a295ef92d43809e914b70c39fd5a23cf3f1"
  integrity sha512-mwiok6cy7KTW7rBpo05k6+p4YVZByLNjAZ/ACB9DRCu4YDRwjXI01tWHp6KAUWelsBetTxKK/2sHB0vdS8Z2Og==
eslint@^8.40.0:
  version "8.40.0"
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.40.0.tgz#a564cd0099f38542c4e9a2f630fa45bf33bc42a4"
  integrity sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==
  dependencies:
    "@eslint-community/eslint-utils" "^4.2.0"
    "@eslint-community/regexpp" "^4.4.0"
    "@eslint/eslintrc" "^2.0.2"
    "@eslint/js" "8.39.0"
    "@eslint/eslintrc" "^2.0.3"
    "@eslint/js" "8.40.0"
    "@humanwhocodes/config-array" "^0.11.8"
    "@humanwhocodes/module-importer" "^1.0.1"
    "@nodelib/fs.walk" "^1.2.8"


@@ 5187,8 5263,8 @@ eslint@^8.39.0:
    doctrine "^3.0.0"
    escape-string-regexp "^4.0.0"
    eslint-scope "^7.2.0"
    eslint-visitor-keys "^3.4.0"
    espree "^9.5.1"
    eslint-visitor-keys "^3.4.1"
    espree "^9.5.2"
    esquery "^1.4.2"
    esutils "^2.0.2"
    fast-deep-equal "^3.1.3"


@@ 5214,14 5290,14 @@ eslint@^8.39.0:
    strip-json-comments "^3.1.0"
    text-table "^0.2.0"

espree@^9.5.1:
  version "9.5.1"
  resolved "https://registry.yarnpkg.com/espree/-/espree-9.5.1.tgz#4f26a4d5f18905bf4f2e0bd99002aab807e96dd4"
  integrity sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==
espree@^9.5.2:
  version "9.5.2"
  resolved "https://registry.yarnpkg.com/espree/-/espree-9.5.2.tgz#e994e7dc33a082a7a82dceaf12883a829353215b"
  integrity sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==
  dependencies:
    acorn "^8.8.0"
    acorn-jsx "^5.3.2"
    eslint-visitor-keys "^3.4.0"
    eslint-visitor-keys "^3.4.1"

esprima@^4.0.0, esprima@^4.0.1:
  version "4.0.1"


@@ 5325,7 5401,7 @@ execa@^5.0.0:
    signal-exit "^3.0.3"
    strip-final-newline "^2.0.0"

execa@^7.0.0:
execa@^7.0.0, execa@^7.1.1:
  version "7.1.1"
  resolved "https://registry.yarnpkg.com/execa/-/execa-7.1.1.tgz#3eb3c83d239488e7b409d48e8813b76bb55c9c43"
  integrity sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==


@@ 5457,7 5533,7 @@ fast-diff@^1.1.2:
  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
  integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==

fast-glob@^3.2.12, fast-glob@^3.2.9:
fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.9:
  version "3.2.12"
  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
  integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==


@@ 5829,6 5905,11 @@ get-symbol-description@^1.0.0:
    call-bind "^1.0.2"
    get-intrinsic "^1.1.1"

get-tsconfig@^4.5.0:
  version "4.5.0"
  resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.5.0.tgz#6d52d1c7b299bd3ee9cd7638561653399ac77b0f"
  integrity sha512-MjhiaIWCJ1sAU4pIQ5i5OfOuHHxVo1oYeNsWTON7jxYkod8pHocXeh+SSbmu5OZZZK73B6cbJ2XADzXehLyovQ==

get-value@^2.0.3, get-value@^2.0.6:
  version "2.0.6"
  resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"


@@ 5939,6 6020,17 @@ globby@^11.1.0:
    merge2 "^1.4.1"
    slash "^3.0.0"

globby@^13.1.3:
  version "13.1.4"
  resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.4.tgz#2f91c116066bcec152465ba36e5caa4a13c01317"
  integrity sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==
  dependencies:
    dir-glob "^3.0.1"
    fast-glob "^3.2.11"
    ignore "^5.2.0"
    merge2 "^1.4.1"
    slash "^4.0.0"

globby@^6.1.0:
  version "6.1.0"
  resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"


@@ 5967,6 6059,11 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0,
  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96"
  integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==

graceful-fs@^4.2.4:
  version "4.2.11"
  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==

grapheme-splitter@^1.0.4:
  version "1.0.4"
  resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"


@@ 6622,6 6719,16 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2:
    is-data-descriptor "^1.0.0"
    kind-of "^6.0.2"

is-docker@^2.0.0:
  version "2.2.1"
  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
  integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==

is-docker@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200"
  integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==

is-electron@^2.2.0:
  version "2.2.0"
  resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.0.tgz#8943084f09e8b731b3a7a0298a7b5d56f6b7eef0"


@@ 6678,6 6785,13 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
  dependencies:
    is-extglob "^2.1.1"

is-inside-container@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4"
  integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==
  dependencies:
    is-docker "^3.0.0"

is-map@^2.0.1, is-map@^2.0.2:
  version "2.0.2"
  resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127"


@@ 6861,6 6975,13 @@ is-wsl@^1.1.0:
  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d"
  integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=

is-wsl@^2.2.0:
  version "2.2.0"
  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
  integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
  dependencies:
    is-docker "^2.0.0"

isarray@0.0.1:
  version "0.0.1"
  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"


@@ 8145,10 8266,10 @@ mkdirp@^1.0, mkdirp@^1.0.3, mkdirp@^1.0.4:
  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==

mkdirp@^2.1.6:
  version "2.1.6"
  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19"
  integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==
mkdirp@^3.0.1:
  version "3.0.1"
  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
  integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==

mousetrap@^1.5.2:
  version "1.6.5"


@@ 8508,6 8629,16 @@ onetime@^6.0.0:
  dependencies:
    mimic-fn "^4.0.0"

open@^9.1.0:
  version "9.1.0"
  resolved "https://registry.yarnpkg.com/open/-/open-9.1.0.tgz#684934359c90ad25742f5a26151970ff8c6c80b6"
  integrity sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==
  dependencies:
    default-browser "^4.0.0"
    define-lazy-prop "^3.0.0"
    is-inside-container "^1.0.0"
    is-wsl "^2.2.0"

opencollective-postinstall@^2.0.2:
  version "2.0.3"
  resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"


@@ 8786,15 8917,10 @@ performance-now@^2.1.0:
  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=

pg-connection-string@^2.4.0:
  version "2.4.0"
  resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10"
  integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ==

pg-connection-string@^2.5.0:
  version "2.5.0"
  resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34"
  integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
pg-connection-string@^2.4.0, pg-connection-string@^2.6.0:
  version "2.6.0"
  resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.0.tgz#12a36cc4627df19c25cc1b9b736cc39ee1f73ae8"
  integrity sha512-x14ibktcwlHKoHxx9X3uTVW9zIGR41ZB6QNhHb21OPNdCCO3NaRnpJuwKIQSR4u+Yqjx4HCvy7Hh7VSy1U4dGg==

pg-int8@1.0.1:
  version "1.0.1"


@@ 10114,6 10240,13 @@ rrweb-cssom@^0.6.0:
  resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1"
  integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==

run-applescript@^5.0.0:
  version "5.0.0"
  resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-5.0.0.tgz#e11e1c932e055d5c6b40d98374e0268d9b11899c"
  integrity sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==
  dependencies:
    execa "^5.0.0"

run-parallel@^1.1.9:
  version "1.2.0"
  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"


@@ 10251,10 10384,10 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==

semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.0:
  version "7.5.0"
  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.0.tgz#ed8c5dc8efb6c629c88b23d41dc9bf40c1d96cd0"
  integrity sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==
semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.1:
  version "7.5.1"
  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec"
  integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==
  dependencies:
    lru-cache "^6.0.0"



@@ 10426,6 10559,11 @@ slash@^3.0.0:
  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==

slash@^4.0.0:
  version "4.0.0"
  resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
  integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==

slice-ansi@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787"


@@ 11070,6 11208,14 @@ symbol-tree@^3.2.4:
  resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
  integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==

synckit@^0.8.5:
  version "0.8.5"
  resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.5.tgz#b7f4358f9bb559437f9f167eb6bc46b3c9818fa3"
  integrity sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==
  dependencies:
    "@pkgr/utils" "^2.3.1"
    tslib "^2.5.0"

table@^6.8.1:
  version "6.8.1"
  resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf"


@@ 11086,6 11232,11 @@ tapable@^1.0, tapable@^1.0.0, tapable@^1.1.3:
  resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
  integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==

tapable@^2.2.0:
  version "2.2.1"
  resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"
  integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==

tar@^6.0.2:
  version "6.1.11"
  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"


@@ 11208,6 11359,11 @@ tiny-warning@^1.0.0:
  resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
  integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==

titleize@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53"
  integrity sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==

tmpl@1.0.5:
  version "1.0.5"
  resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"


@@ 11316,7 11472,7 @@ tsconfig-paths@^3.14.1:
    minimist "^1.2.6"
    strip-bom "^3.0.0"

tslib@2.5.0, tslib@^2.1.0, tslib@^2.4.0:
tslib@2.5.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.5.0:
  version "2.5.0"
  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
  integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==


@@ 11534,6 11690,11 @@ unset-value@^1.0.0:
    has-value "^0.3.1"
    isobject "^3.0.0"

untildify@^4.0.0:
  version "4.0.0"
  resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"
  integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==

upath@^1.1.1, upath@^1.2.0:
  version "1.2.0"
  resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"