~cytrogen/masto-fe

0222df6047a8569d04cfdc36668d46b71bec2e75 — Claire 2 years ago 646cde7 + 1347ca6
Merge pull request #2236 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to e387175fc9a3ebfd72ab45ebfe43ecfabef7b0c3
335 files changed, 3902 insertions(+), 1517 deletions(-)

M .eslintrc.js
M .github/workflows/test-migrations-one-step.yml
M .github/workflows/test-migrations-two-step.yml
M .rubocop_todo.yml
M Dockerfile
M Gemfile
M Gemfile.lock
M app/controllers/api/v1/admin/canonical_email_blocks_controller.rb
M app/controllers/api/v1/admin/domain_allows_controller.rb
M app/controllers/api/v1/emails/confirmations_controller.rb
M app/controllers/api/v1/featured_tags_controller.rb
M app/controllers/api/v1/statuses/reblogs_controller.rb
M app/controllers/auth/registrations_controller.rb
M app/controllers/auth/setup_controller.rb
M app/controllers/oauth/authorized_applications_controller.rb
M app/javascript/core/theme.yml
M app/javascript/flavours/glitch/actions/app.ts
M app/javascript/flavours/glitch/actions/pin_statuses.js
M app/javascript/flavours/glitch/components/account.jsx
M app/javascript/flavours/glitch/components/admin/Counter.jsx
M app/javascript/flavours/glitch/components/admin/Dimension.jsx
M app/javascript/flavours/glitch/components/animated_number.tsx
M app/javascript/flavours/glitch/components/autosuggest_input.jsx
M app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
M app/javascript/flavours/glitch/components/avatar.tsx
M app/javascript/flavours/glitch/components/blurhash.tsx
M app/javascript/flavours/glitch/components/column.jsx
D app/javascript/flavours/glitch/components/display_name.jsx
A app/javascript/flavours/glitch/components/display_name.tsx
M app/javascript/flavours/glitch/components/domain.tsx
M app/javascript/flavours/glitch/components/dropdown_menu.jsx
M app/javascript/flavours/glitch/components/gifv.tsx
M app/javascript/flavours/glitch/components/hashtag.jsx
M app/javascript/flavours/glitch/components/icon.tsx
M app/javascript/flavours/glitch/components/icon_button.tsx
M app/javascript/flavours/glitch/components/icon_with_badge.tsx
M app/javascript/flavours/glitch/components/media_gallery.jsx
M app/javascript/flavours/glitch/components/modal_root.jsx
M app/javascript/flavours/glitch/components/not_signed_in_indicator.tsx
M app/javascript/flavours/glitch/components/radio_button.tsx
M app/javascript/flavours/glitch/components/relative_timestamp.tsx
M app/javascript/flavours/glitch/components/scrollable_list.jsx
M app/javascript/flavours/glitch/components/server_banner.jsx
R app/javascript/flavours/glitch/components/{image => server_hero_image}.tsx
D app/javascript/flavours/glitch/components/skeleton.jsx
A app/javascript/flavours/glitch/components/skeleton.tsx
M app/javascript/flavours/glitch/components/status.jsx
M app/javascript/flavours/glitch/components/status_header.jsx
M app/javascript/flavours/glitch/components/status_list.jsx
D app/javascript/flavours/glitch/components/timeline_hint.jsx
A app/javascript/flavours/glitch/components/timeline_hint.tsx
M app/javascript/flavours/glitch/containers/media_container.jsx
M app/javascript/flavours/glitch/containers/status_container.js
M app/javascript/flavours/glitch/features/about/index.jsx
M app/javascript/flavours/glitch/features/account_gallery/index.jsx
M app/javascript/flavours/glitch/features/account_timeline/components/moved_note.jsx
M app/javascript/flavours/glitch/features/account_timeline/index.jsx
M app/javascript/flavours/glitch/features/audio/index.jsx
M app/javascript/flavours/glitch/features/blocks/index.jsx
M app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx
M app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx
M app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx
M app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx
M app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx
M app/javascript/flavours/glitch/features/directory/components/account_card.jsx
M app/javascript/flavours/glitch/features/domain_blocks/index.jsx
M app/javascript/flavours/glitch/features/explore/components/story.jsx
M app/javascript/flavours/glitch/features/favourited_statuses/index.jsx
M app/javascript/flavours/glitch/features/favourites/index.jsx
M app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx
M app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx
M app/javascript/flavours/glitch/features/follow_requests/index.jsx
M app/javascript/flavours/glitch/features/followers/index.jsx
M app/javascript/flavours/glitch/features/following/index.jsx
M app/javascript/flavours/glitch/features/getting_started/index.jsx
M app/javascript/flavours/glitch/features/interaction_modal/index.jsx
M app/javascript/flavours/glitch/features/list_adder/components/account.jsx
M app/javascript/flavours/glitch/features/list_editor/components/account.jsx
M app/javascript/flavours/glitch/features/list_timeline/index.jsx
M app/javascript/flavours/glitch/features/lists/index.jsx
M app/javascript/flavours/glitch/features/mutes/index.jsx
M app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx
M app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx
M app/javascript/flavours/glitch/features/pinned_statuses/index.jsx
M app/javascript/flavours/glitch/features/privacy_policy/index.jsx
M app/javascript/flavours/glitch/features/reblogs/index.jsx
M app/javascript/flavours/glitch/features/report/components/status_check_box.jsx
M app/javascript/flavours/glitch/features/status/components/card.jsx
M app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
M app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
M app/javascript/flavours/glitch/features/status/index.jsx
M app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx
M app/javascript/flavours/glitch/features/ui/components/boost_modal.jsx
M app/javascript/flavours/glitch/features/ui/components/bundle.jsx
M app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
M app/javascript/flavours/glitch/features/ui/components/embed_modal.jsx
M app/javascript/flavours/glitch/features/ui/components/favourite_modal.jsx
M app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx
M app/javascript/flavours/glitch/features/ui/components/header.jsx
M app/javascript/flavours/glitch/features/ui/components/media_modal.jsx
M app/javascript/flavours/glitch/features/ui/components/onboarding_modal.jsx
M app/javascript/flavours/glitch/features/ui/components/sign_in_banner.jsx
M app/javascript/flavours/glitch/features/ui/components/upload_area.jsx
M app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
M app/javascript/flavours/glitch/features/ui/index.jsx
M app/javascript/flavours/glitch/features/video/index.jsx
M app/javascript/flavours/glitch/is_mobile.ts
M app/javascript/flavours/glitch/main.jsx
M app/javascript/flavours/glitch/packs/admin.jsx
M app/javascript/flavours/glitch/packs/common.js
M app/javascript/flavours/glitch/packs/public.jsx
M app/javascript/flavours/glitch/packs/share.jsx
A app/javascript/flavours/glitch/packs/sign_up.js
M app/javascript/flavours/glitch/polyfills/base_polyfills.ts
M app/javascript/flavours/glitch/reducers/index.ts
M app/javascript/flavours/glitch/reducers/markers.js
M app/javascript/flavours/glitch/store/index.ts
M app/javascript/flavours/glitch/store/middlewares/errors.ts
M app/javascript/flavours/glitch/store/middlewares/loading_bar.ts
M app/javascript/flavours/glitch/store/middlewares/sounds.ts
M app/javascript/flavours/glitch/styles/components/media.scss
M app/javascript/flavours/glitch/theme.yml
M app/javascript/flavours/glitch/types/resources.ts
M app/javascript/flavours/glitch/utils/dom_helpers.js
M app/javascript/flavours/glitch/utils/resize_image.js
M app/javascript/flavours/glitch/uuid.ts
M app/javascript/flavours/vanilla/theme.yml
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/admin/Counter.jsx
M app/javascript/mastodon/components/admin/Dimension.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
M app/javascript/mastodon/components/column.jsx
D app/javascript/mastodon/components/display_name.jsx
A app/javascript/mastodon/components/display_name.tsx
M app/javascript/mastodon/components/domain.tsx
M app/javascript/mastodon/components/dropdown_menu.jsx
A app/javascript/mastodon/components/empty_account.tsx
M app/javascript/mastodon/components/gifv.tsx
M app/javascript/mastodon/components/hashtag.jsx
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/scrollable_list.jsx
M app/javascript/mastodon/components/server_banner.jsx
R app/javascript/mastodon/components/{image => server_hero_image}.tsx
D app/javascript/mastodon/components/skeleton.jsx
A app/javascript/mastodon/components/skeleton.tsx
M app/javascript/mastodon/components/status.jsx
M app/javascript/mastodon/components/status_list.jsx
D app/javascript/mastodon/components/timeline_hint.jsx
A app/javascript/mastodon/components/timeline_hint.tsx
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/language_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/explore/components/story.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/followers/index.jsx
M app/javascript/mastodon/features/following/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/privacy_policy/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/embed_modal.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/main.jsx
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/utils/__tests__/html-test.js
M app/javascript/mastodon/uuid.ts
M app/javascript/packs/admin.jsx
M app/javascript/packs/public.jsx
M app/javascript/packs/share.jsx
A app/javascript/packs/sign_up.js
M app/javascript/styles/mastodon/components.scss
M app/javascript/styles/mastodon/forms.scss
M app/javascript/types/image.d.ts
M app/javascript/types/resources.ts
M app/lib/account_reach_finder.rb
M app/lib/activitypub/activity/flag.rb
M app/lib/activitypub/tag_manager.rb
M app/lib/admin/metrics/dimension.rb
M app/lib/admin/metrics/measure.rb
M app/lib/application_extension.rb
M app/lib/extractor.rb
M app/lib/feed_manager.rb
M app/lib/link_details_extractor.rb
M app/lib/vacuum/access_tokens_vacuum.rb
M app/models/account.rb
M app/models/account_statuses_cleanup_policy.rb
M app/models/account_suggestions/setting_source.rb
M app/models/account_suggestions/source.rb
M app/models/follow_recommendation_filter.rb
M app/models/form/account_batch.rb
M app/models/form/admin_settings.rb
M app/models/notification.rb
M app/models/report.rb
M app/models/user_role.rb
M app/models/webhook.rb
M app/services/backup_service.rb
M app/services/process_mentions_service.rb
M app/validators/existing_username_validator.rb
M app/validators/vote_validator.rb
M app/views/admin/reports/_media_attachments.html.haml
M app/views/admin/settings/registrations/show.html.haml
M app/views/auth/confirmations/captcha.html.haml
M app/views/oauth/authorized_applications/index.html.haml
M app/workers/post_process_media_worker.rb
M config/initializers/ffmpeg.rb
M config/initializers/omniauth.rb
M config/initializers/paperclip.rb
M config/initializers/rack_attack.rb
M config/initializers/webauthn.rb
M config/locales/devise.en.yml
M config/locales/en.yml
M config/routes/api.rb
M config/settings.yml
M config/webpack/generateLocalePacks.js
M db/migrate/20200407202420_migrate_unavailable_inboxes.rb
M jest.config.js
M lib/mastodon/media_cli.rb
M lib/tasks/tests.rake
M package.json
M spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
M spec/controllers/activitypub/outboxes_controller_spec.rb
M spec/controllers/admin/announcements_controller_spec.rb
M spec/controllers/admin/confirmations_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/api/v1/admin/domain_allows_controller_spec.rb
M spec/controllers/api/v1/admin/email_domain_blocks_controller_spec.rb
M spec/controllers/api/v1/admin/ip_blocks_controller_spec.rb
M spec/controllers/api/v1/emails/confirmations_controller_spec.rb
D spec/controllers/api/v1/featured_tags_controller_spec.rb
M spec/controllers/auth/registrations_controller_spec.rb
M spec/controllers/concerns/signature_verification_spec.rb
M spec/controllers/statuses_controller_spec.rb
M spec/fabricators/canonical_email_block_fabricator.rb
M spec/fabricators/featured_tag_fabricator.rb
M spec/fabricators/notification_fabricator.rb
A spec/features/captcha_spec.rb
A spec/lib/account_reach_finder_spec.rb
M spec/lib/activitypub/activity/flag_spec.rb
A spec/lib/mastodon/ip_blocks_cli_spec.rb
A spec/lib/mastodon/migration_warning_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
M spec/models/account_migration_spec.rb
M spec/models/account_spec.rb
A spec/models/form/account_batch_spec.rb
M spec/models/report_spec.rb
M spec/models/user_settings/setting_spec.rb
M spec/policies/report_note_policy_spec.rb
M spec/policies/status_policy_spec.rb
M spec/presenters/status_relationships_presenter_spec.rb
A spec/requests/api/v1/featured_tags_spec.rb
M spec/services/activitypub/process_account_service_spec.rb
M spec/services/backup_service_spec.rb
M spec/services/report_service_spec.rb
M spec/services/unsuspend_account_service_spec.rb
M tsconfig.json
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/**,flavours/glitch-soc/**}',
                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 .github/workflows/test-migrations-one-step.yml => .github/workflows/test-migrations-one-step.yml +9 -1
@@ 23,9 23,17 @@ jobs:
    needs: pre_job
    if: needs.pre_job.outputs.should_skip != 'true'

    strategy:
      fail-fast: false

      matrix:
        postgres:
          - 14-alpine
          - 15-alpine

    services:
      postgres:
        image: postgres:14-alpine
        image: postgres:${{ matrix.postgres}}
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_USER: postgres

M .github/workflows/test-migrations-two-step.yml => .github/workflows/test-migrations-two-step.yml +9 -1
@@ 23,9 23,17 @@ jobs:
    needs: pre_job
    if: needs.pre_job.outputs.should_skip != 'true'

    strategy:
      fail-fast: false

      matrix:
        postgres:
          - 14-alpine
          - 15-alpine

    services:
      postgres:
        image: postgres:14-alpine
        image: postgres:${{ matrix.postgres}}
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_USER: postgres

M .rubocop_todo.yml => .rubocop_todo.yml +0 -90
@@ 22,12 22,6 @@ Layout/ArgumentAlignment:
    - 'config/initializers/session_store.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment.
Layout/ExtraSpacing:
  Exclude:
    - 'config/initializers/omniauth.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle.
# SupportedHashRocketStyles: key, separator, table
# SupportedColonStyles: key, separator, table


@@ 40,12 34,6 @@ Layout/HashAlignment:
    - 'config/routes.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Width, AllowedPatterns.
Layout/IndentationWidth:
  Exclude:
    - 'config/initializers/ffmpeg.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowDoxygenCommentStyle, AllowGemfileRubyComment.
Layout/LeadingCommentSpace:
  Exclude:


@@ 53,14 41,6 @@ Layout/LeadingCommentSpace:
    - 'config/initializers/omniauth.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces.
# SupportedStyles: space, no_space
# SupportedStylesForEmptyBraces: space, no_space
Layout/SpaceBeforeBlockBraces:
  Exclude:
    - 'config/initializers/paperclip.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: require_no_space, require_space
Layout/SpaceInLambdaLiteral:


@@ 68,19 48,6 @@ Layout/SpaceInLambdaLiteral:
    - 'config/environments/production.rb'
    - 'config/initializers/content_security_policy.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: space, no_space
Layout/SpaceInsideStringInterpolation:
  Exclude:
    - 'config/initializers/webauthn.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowInHeredoc.
Layout/TrailingWhitespace:
  Exclude:
    - 'config/initializers/paperclip.rb'

# Configuration parameters: AllowedMethods, AllowedPatterns.
Lint/AmbiguousBlockAssociation:
  Exclude:


@@ 94,11 61,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:


@@ 278,31 240,6 @@ Naming/VariableNumber:
    - 'spec/services/activitypub/fetch_featured_collection_service_spec.rb'

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

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

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


@@ 626,7 563,6 @@ RSpec/NoExpectationExample:

RSpec/PendingWithoutReason:
  Exclude:
    - 'spec/controllers/statuses_controller_spec.rb'
    - 'spec/models/account_spec.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).


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

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

RSpec/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 Dockerfile => Dockerfile +1 -1
@@ 55,7 55,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND="noninteractive" \
    PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin"

# Ignoreing these here since we don't want to pin any versions and the Debian image removes apt-get content after use
# Ignoring these here since we don't want to pin any versions and the Debian image removes apt-get content after use
# hadolint ignore=DL3008,DL3009
RUN apt-get update && \
    echo "Etc/UTC" > /etc/localtime && \

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

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


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


@@ 99,54 99,87 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5'

group :development, :test do
  gem 'fabrication', '~> 2.30'
  gem 'fuubar', '~> 2.5'
  gem 'i18n-tasks', '~> 1.0', require: false
gem 'private_address_check', '~> 0.5'

group :test do
  # RSpec runner for rails
  gem 'rspec-rails', '~> 6.0'

  # Used to split testing into chunks in CI
  gem 'rspec_chunked', '~> 0.6'

  gem 'rubocop-capybara', require: false
  gem 'rubocop-performance', require: false
  gem 'rubocop-rails', require: false
  gem 'rubocop-rspec', require: false
  gem 'rubocop', require: false
end
  # RSpec progress bar formatter
  gem 'fuubar', '~> 2.5'

group :production, :test do
  gem 'private_address_check', '~> 0.5'
end
  # Extra RSpec extenion methods and helpers for sidekiq
  gem 'rspec-sidekiq', '~> 3.1'

group :test do
  # Browser integration testing
  gem 'capybara', '~> 3.39'
  gem 'climate_control'

  # Used to mock environment variables
  gem 'climate_control', '~> 0.2'

  # Generating fake data for specs
  gem 'faker', '~> 3.2'

  # Generate test objects for specs
  gem 'fabrication', '~> 2.30'

  # Add back helpers functions removed in Rails 5.1
  gem 'rails-controller-testing', '~> 1.0'

  # Validate schemas in specs
  gem 'json-schema', '~> 4.0'

  # Test harness fo rack components
  gem 'rack-test', '~> 2.1'
  gem 'rails-controller-testing', '~> 1.0'
  gem 'rspec_junit_formatter', '~> 0.6'
  gem 'rspec-sidekiq', '~> 3.1'

  # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false
  gem 'simplecov', '~> 0.22', require: false

  # Stub web requests for specs
  gem 'webmock', '~> 3.18'
end

group :development do
  # Code linting CLI and plugins
  gem 'rubocop', require: false
  gem 'rubocop-capybara', require: false
  gem 'rubocop-performance', require: false
  gem 'rubocop-rails', require: false
  gem 'rubocop-rspec', require: false

  # Annotates modules with schema
  gem 'annotate', '~> 3.2'

  # Enhanced error message pages for development
  gem 'better_errors', '~> 2.9'
  gem 'binding_of_caller', '~> 1.0'

  # Preview mail in the browser
  gem 'letter_opener', '~> 1.8'
  gem 'letter_opener_web', '~> 2.0'
  gem 'memory_profiler'

  # Security analysis CLI tools
  gem 'brakeman', '~> 5.4', require: false
  gem 'bundler-audit', '~> 0.9', require: false

  # Linter CLI for HAML files
  gem 'haml_lint', require: false

  # Deployment automation
  gem 'capistrano', '~> 3.17'
  gem 'capistrano-rails', '~> 1.6'
  gem 'capistrano-rbenv', '~> 2.2'
  gem 'capistrano-yarn', '~> 2.0'

  gem 'stackprof'
  # Validate missing i18n keys
  gem 'i18n-tasks', '~> 1.0', require: false

  # Profiling tools
  gem 'memory_profiler', require: false
  gem 'stackprof', require: false
end

group :production do


@@ 157,8 190,9 @@ gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'

gem 'hcaptcha', '~> 7.1'
gem 'cocoon', '~> 1.2'

gem 'net-http', '~> 0.3.2'
gem 'rubyzip', '~> 2.3'

gem 'hcaptcha', '~> 7.1'

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


@@ 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)


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


@@ 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)


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


@@ 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)


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


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


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


@@ 698,7 696,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)


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

PLATFORMS
  ruby


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


@@ 787,7 785,7 @@ DEPENDENCIES
  capybara (~> 3.39)
  charlock_holmes (~> 0.7.7)
  chewy (~> 7.3)
  climate_control
  climate_control (~> 0.2)
  cocoon (~> 1.2)
  color_diff (~> 0.1)
  concurrent-ruby


@@ 862,11 860,10 @@ DEPENDENCIES
  redcarpet (~> 3.6)
  redis (~> 4.5)
  redis-namespace (~> 1.10)
  rqrcode (~> 2.1)
  rqrcode (~> 2.2)
  rspec-rails (~> 6.0)
  rspec-sidekiq (~> 3.1)
  rspec_chunked (~> 0.6)
  rspec_junit_formatter (~> 0.6)
  rubocop
  rubocop-capybara
  rubocop-performance

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/controllers/api/v1/admin/domain_allows_controller.rb => app/controllers/api/v1/admin/domain_allows_controller.rb +1 -1
@@ 29,7 29,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
  def create
    authorize :domain_allow, :create?

    @domain_allow = DomainAllow.find_by(resource_params)
    @domain_allow = DomainAllow.find_by(domain: resource_params[:domain])

    if @domain_allow.nil?
      @domain_allow = DomainAllow.create!(resource_params)

M app/controllers/api/v1/emails/confirmations_controller.rb => app/controllers/api/v1/emails/confirmations_controller.rb +8 -3
@@ 1,9 1,10 @@
# frozen_string_literal: true

class Api::V1::Emails::ConfirmationsController < Api::BaseController
  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
  before_action :require_user_owned_by_application!
  before_action :require_user_not_confirmed!
  before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :check
  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :check
  before_action :require_user_owned_by_application!, except: :check
  before_action :require_user_not_confirmed!, except: :check

  def create
    current_user.update!(email: params[:email]) if params.key?(:email)


@@ 12,6 13,10 @@ class Api::V1::Emails::ConfirmationsController < Api::BaseController
    render_empty
  end

  def check
    render json: current_user.confirmed?
  end

  private

  def require_user_owned_by_application!

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

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



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

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

M app/controllers/api/v1/statuses/reblogs_controller.rb => app/controllers/api/v1/statuses/reblogs_controller.rb +5 -1
@@ 2,6 2,8 @@

class Api::V1::Statuses::ReblogsController < Api::BaseController
  include Authorization
  include Redisable
  include Lockable

  before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
  before_action :require_user!


@@ 10,7 12,9 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
  override_rate_limit_headers :create, family: :statuses

  def create
    @status = ReblogService.new.call(current_account, @reblog, reblog_params)
    with_redis_lock("reblog:#{current_account.id}:#{@reblog.id}") do
      @status = ReblogService.new.call(current_account, @reblog, reblog_params)
    end

    render json: @status, serializer: REST::StatusSerializer
  end

M app/controllers/auth/registrations_controller.rb => app/controllers/auth/registrations_controller.rb +1 -1
@@ 132,7 132,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
  end

  def set_sessions
    @sessions = current_user.session_activations
    @sessions = current_user.session_activations.order(updated_at: :desc)
  end

  def set_strikes

M app/controllers/auth/setup_controller.rb => app/controllers/auth/setup_controller.rb +1 -1
@@ 45,6 45,6 @@ class Auth::SetupController < ApplicationController
  end

  def set_pack
    use_pack 'auth'
    use_pack 'sign_up'
  end
end

M app/controllers/oauth/authorized_applications_controller.rb => app/controllers/oauth/authorized_applications_controller.rb +12 -0
@@ 10,6 10,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
  before_action :set_body_classes
  before_action :set_cache_headers

  before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json }

  skip_before_action :require_functional!

  include Localized


@@ 40,4 42,14 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
  def set_cache_headers
    response.cache_control.replace(private: true, no_store: true)
  end

  def set_last_used_at_by_app
    @last_used_at_by_app = Doorkeeper::AccessToken
                           .select('DISTINCT ON (application_id) application_id, last_used_at')
                           .where(resource_owner_id: current_resource_owner.id)
                           .where.not(last_used_at: nil)
                           .order(application_id: :desc, last_used_at: :desc)
                           .pluck(:application_id, :last_used_at)
                           .to_h
  end
end

M app/javascript/core/theme.yml => app/javascript/core/theme.yml +1 -0
@@ 16,4 16,5 @@ pack:
  modal: public.js
  public: public.js
  settings: settings.js
  sign_up:
  share:

M app/javascript/flavours/glitch/actions/app.ts => app/javascript/flavours/glitch/actions/app.ts +3 -2
@@ 1,8 1,9 @@
import { createAction } from '@reduxjs/toolkit';

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

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

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

import { me } from 'flavours/glitch/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 'flavours/glitch/initial_state';

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

M app/javascript/flavours/glitch/components/account.jsx => app/javascript/flavours/glitch/components/account.jsx +2 -2
@@ 2,14 2,14 @@ import React, { Fragment } 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 Permalink from './permalink';
import { IconButton } from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from 'flavours/glitch/initial_state';
import { RelativeTimestamp } from './relative_timestamp';
import Skeleton from 'flavours/glitch/components/skeleton';
import { Skeleton } from 'flavours/glitch/components/skeleton';

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

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

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

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

export default class Dimension extends React.PureComponent {


M app/javascript/flavours/glitch/components/animated_number.tsx => app/javascript/flavours/glitch/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/flavours/glitch/components/autosuggest_input.jsx => app/javascript/flavours/glitch/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/flavours/glitch/components/autosuggest_textarea.jsx => app/javascript/flavours/glitch/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/flavours/glitch/components/avatar.tsx => app/javascript/flavours/glitch/components/avatar.tsx +5 -3
@@ 1,16 1,18 @@
import * as React from 'react';

import classNames from 'classnames';
import { autoPlayGif } from 'flavours/glitch/initial_state';

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

type Props = {
interface Props {
  account: Account | undefined;
  className?: string;
  size: number;
  style?: React.CSSProperties;
  inline?: boolean;
};
}

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

M app/javascript/flavours/glitch/components/blurhash.tsx => app/javascript/flavours/glitch/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


M app/javascript/flavours/glitch/components/column.jsx => app/javascript/flavours/glitch/components/column.jsx +6 -4
@@ 3,6 3,8 @@ import PropTypes from 'prop-types';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollTop } from '../scroll';

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;

export default class Column extends React.PureComponent {

  static propTypes = {


@@ 37,17 39,17 @@ export default class Column extends React.PureComponent {

  componentDidMount () {
    if (this.props.bindToDocument) {
      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
      document.addEventListener('wheel', this.handleWheel, listenerOptions);
    } else {
      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
      this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }

  componentWillUnmount () {
    if (this.props.bindToDocument) {
      document.removeEventListener('wheel', this.handleWheel);
      document.removeEventListener('wheel', this.handleWheel, listenerOptions);
    } else {
      this.node.removeEventListener('wheel', this.handleWheel);
      this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }


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

export default class DisplayName extends React.PureComponent {

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

  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, inline } = 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={classNames('display-name', { inline })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
        {displayName}
        {inline ? ' ' : null}
        {suffix}
      </span>
    );
  }

}

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

import classNames from 'classnames';

import type { List } from 'immutable';

import type { Account } from 'flavours/glitch/types/resources';

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

import { Skeleton } from './skeleton';

interface Props {
  account: Account;
  others: List<Account>;
  localDomain: string;
  inline?: boolean;
}
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, inline } = this.props;

    let displayName: React.ReactNode, suffix: React.ReactNode, account: 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={classNames('display-name', { inline })}
        onMouseEnter={this.handleMouseEnter}
        onMouseLeave={this.handleMouseLeave}
      >
        {displayName}
        {inline ? ' ' : null}
        {suffix}
      </span>
    );
  }
}

M app/javascript/flavours/glitch/components/domain.tsx => app/javascript/flavours/glitch/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);

M app/javascript/flavours/glitch/components/dropdown_menu.jsx => app/javascript/flavours/glitch/components/dropdown_menu.jsx +6 -5
@@ 7,7 7,7 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
import { CircularProgress } from 'flavours/glitch/components/loading_indicator';

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
let id = 0;

class DropdownMenu extends React.PureComponent {


@@ 35,12 35,13 @@ class DropdownMenu extends React.PureComponent {
  handleDocumentClick = e => {
    if (this.node && !this.node.contains(e.target)) {
      this.props.onClose();
      e.stopPropagation();
    }
  };

  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('keydown', this.handleKeyDown, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('keydown', this.handleKeyDown, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);

    if (this.focusedItem && this.props.openedViaKeyboard) {


@@ 49,8 50,8 @@ class DropdownMenu extends React.PureComponent {
  }

  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('keydown', this.handleKeyDown, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }


M app/javascript/flavours/glitch/components/gifv.tsx => app/javascript/flavours/glitch/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/flavours/glitch/components/hashtag.jsx => app/javascript/flavours/glitch/components/hashtag.jsx +1 -1
@@ 6,7 6,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink';
import ShortNumber from 'flavours/glitch/components/short_number';
import Skeleton from 'flavours/glitch/components/skeleton';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import classNames from 'classnames';

class SilentErrorBoundary extends React.Component {

M app/javascript/flavours/glitch/components/icon.tsx => app/javascript/flavours/glitch/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/flavours/glitch/components/icon_button.tsx => app/javascript/flavours/glitch/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;


@@ 26,11 28,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/flavours/glitch/components/icon_with_badge.tsx => app/javascript/flavours/glitch/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,

M app/javascript/flavours/glitch/components/media_gallery.jsx => app/javascript/flavours/glitch/components/media_gallery.jsx +2 -2
@@ 254,7 254,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) {


@@ 286,7 286,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 = (node) => {

M app/javascript/flavours/glitch/components/modal_root.jsx => app/javascript/flavours/glitch/components/modal_root.jsx +1 -1
@@ 62,7 62,7 @@ export default class ModalRoot extends React.PureComponent {
    }
  }

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


M app/javascript/flavours/glitch/components/not_signed_in_indicator.tsx => app/javascript/flavours/glitch/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/flavours/glitch/components/radio_button.tsx => app/javascript/flavours/glitch/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/flavours/glitch/components/relative_timestamp.tsx => app/javascript/flavours/glitch/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/flavours/glitch/components/scrollable_list.jsx => app/javascript/flavours/glitch/components/scrollable_list.jsx +6 -4
@@ 15,6 15,8 @@ import { connect } from 'react-redux';

const MOUSE_IDLE_DELAY = 300;

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;

const mapStateToProps = (state, { scrollKey }) => {
  return {
    preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),


@@ 237,20 239,20 @@ class ScrollableList extends PureComponent {
  attachScrollListener () {
    if (this.props.bindToDocument) {
      document.addEventListener('scroll', this.handleScroll);
      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : undefined);
      document.addEventListener('wheel', this.handleWheel,  listenerOptions);
    } else {
      this.node.addEventListener('scroll', this.handleScroll);
      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : undefined);
      this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }

  detachScrollListener () {
    if (this.props.bindToDocument) {
      document.removeEventListener('scroll', this.handleScroll);
      document.removeEventListener('wheel', this.handleWheel);
      document.removeEventListener('wheel', this.handleWheel, listenerOptions);
    } else {
      this.node.removeEventListener('scroll', this.handleScroll);
      this.node.removeEventListener('wheel', this.handleWheel);
      this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }


M app/javascript/flavours/glitch/components/server_banner.jsx => app/javascript/flavours/glitch/components/server_banner.jsx +3 -3
@@ 4,10 4,10 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { fetchServer } from 'flavours/glitch/actions/server';
import ShortNumber from 'flavours/glitch/components/short_number';
import Skeleton from 'flavours/glitch/components/skeleton';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import Account from 'flavours/glitch/containers/account_container';
import { domain } from 'flavours/glitch/initial_state';
import { Image } from 'flavours/glitch/components/image';
import { ServerHeroImage } from 'flavours/glitch/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/flavours/glitch/components/image.tsx => app/javascript/flavours/glitch/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,

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

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

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

export default Skeleton;

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

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

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

M app/javascript/flavours/glitch/components/status.jsx => app/javascript/flavours/glitch/components/status.jsx +3 -2
@@ 388,11 388,12 @@ class Status extends ImmutablePureComponent {

  handleOpenVideo = (options) => {
    const { status } = this.props;
    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.props.status.get('id'), media, index);
    const { status } = this.props;
    this.props.onOpenMedia(status.get('id'), media, index, status.get('language'));
  };

  handleHotkeyOpenMedia = e => {

M app/javascript/flavours/glitch/components/status_header.jsx => app/javascript/flavours/glitch/components/status_header.jsx +1 -1
@@ 6,7 6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
//  Mastodon imports.
import { Avatar } from './avatar';
import AvatarOverlay from './avatar_overlay';
import DisplayName from './display_name';
import { DisplayName } from './display_name';

export default class StatusHeader extends React.PureComponent {


M app/javascript/flavours/glitch/components/status_list.jsx => app/javascript/flavours/glitch/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.isRequired,
    lastId: PropTypes.string,
    regex: PropTypes.string,
  };



@@ 56,7 57,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) {

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

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

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

export default TimelineHint;

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

import { FormattedMessage } from 'react-intl';

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

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

M app/javascript/flavours/glitch/containers/media_container.jsx => app/javascript/flavours/glitch/containers/media_container.jsx +8 -6
@@ 1,5 1,5 @@
import React, { PureComponent, Fragment } from 'react';
import ReactDOM from 'react-dom';
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl';
import { fromJS } from 'immutable';


@@ 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 = () => {


@@ 94,7 95,7 @@ export default class MediaContainer extends PureComponent {
              }),
            });

            return ReactDOM.createPortal(
            return createPortal(
              <Component {...props} key={`media-${i}`} />,
              component,
            );


@@ 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/flavours/glitch/containers/status_container.js => app/javascript/flavours/glitch/containers/status_container.js +4 -4
@@ 211,12 211,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/flavours/glitch/features/about/index.jsx => app/javascript/flavours/glitch/features/about/index.jsx +3 -3
@@ 8,10 8,10 @@ import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
import { Helmet } from 'react-helmet';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
import Account from 'flavours/glitch/containers/account_container';
import Skeleton from 'flavours/glitch/components/skeleton';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import { Icon } from 'flavours/glitch/components/icon';
import classNames from 'classnames';
import { Image } from 'flavours/glitch/components/image';
import { ServerHeroImage } from 'flavours/glitch/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/flavours/glitch/features/account_gallery/index.jsx => app/javascript/flavours/glitch/features/account_gallery/index.jsx +4 -3
@@ 142,16 142,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/flavours/glitch/features/account_timeline/components/moved_note.jsx => app/javascript/flavours/glitch/features/account_timeline/components/moved_note.jsx +1 -1
@@ 4,7 4,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 { Icon } from 'flavours/glitch/components/icon';

export default class MovedNote extends ImmutablePureComponent {

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


@@ 12,7 12,7 @@ import HeaderContainer from './containers/header_container';
import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import TimelineHint from 'flavours/glitch/components/timeline_hint';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
import { fetchFeaturedTags } from '../../actions/featured_tags';


@@ 122,7 122,7 @@ class AccountTimeline extends ImmutablePureComponent {
    }
  }

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

    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {

M app/javascript/flavours/glitch/features/audio/index.jsx => app/javascript/flavours/glitch/features/audio/index.jsx +1 -1
@@ 142,7 142,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/flavours/glitch/features/blocks/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/bookmarked_statuses/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/compose/components/autosuggest_account.jsx => app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx +1 -1
@@ 1,6 1,6 @@
import React from 'react';
import { Avatar } from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';


M app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx => app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx +7 -6
@@ 2,12 2,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';

//  Components.
import { Icon } from 'flavours/glitch/components/icon';

//  Utils.
import { withPassive } from 'flavours/glitch/utils/dom_helpers';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;

//  The component.
export default class ComposerOptionsDropdownContent extends React.PureComponent {


@@ 41,6 41,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent 
  handleDocumentClick = (e) => {
    if (this.node && !this.node.contains(e.target)) {
      this.props.onClose();
      e.stopPropagation();
    }
  };



@@ 51,8 52,8 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent 

  //  On mounting, we add our listeners.
  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('touchend', this.handleDocumentClick, withPassive);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
    if (this.focusedItem) {
      this.focusedItem.focus({ preventScroll: true });
    } else {


@@ 62,8 63,8 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent 

  //  On unmounting, we remove our listeners.
  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }

  handleClick = (e) => {

M app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx => app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +6 -6
@@ 28,7 28,7 @@ const messages = defineMessages({

let EmojiPicker, Emoji; // load asynchronously

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;

const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;



@@ 60,7 60,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 {


@@ 79,12 79,12 @@ class ModifierPickerMenu extends React.PureComponent {
  };

  attachListeners () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }

  removeListeners () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }



@@ 177,7 177,7 @@ class EmojiPickerMenuImpl extends React.PureComponent {
  };

  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);

    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need


@@ 192,7 192,7 @@ class EmojiPickerMenuImpl extends React.PureComponent {
  }

  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }


M app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx => app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx +4 -3
@@ 15,7 15,7 @@ const messages = defineMessages({
  clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;

class LanguageDropdownMenu extends React.PureComponent {



@@ 39,11 39,12 @@ class LanguageDropdownMenu extends React.PureComponent {
  handleDocumentClick = e => {
    if (this.node && !this.node.contains(e.target)) {
      this.props.onClose();
      e.stopPropagation();
    }
  };

  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);

    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need


@@ 57,7 58,7 @@ class LanguageDropdownMenu extends React.PureComponent {
  }

  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }


M app/javascript/flavours/glitch/features/directory/components/account_card.jsx => app/javascript/flavours/glitch/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 'flavours/glitch/selectors';
import { Avatar } from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import Permalink from 'flavours/glitch/components/permalink';
import { IconButton } from 'flavours/glitch/components/icon_button';
import Button from 'flavours/glitch/components/button';

M app/javascript/flavours/glitch/features/domain_blocks/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/explore/components/story.jsx => app/javascript/flavours/glitch/features/explore/components/story.jsx +1 -1
@@ 3,7 3,7 @@ import PropTypes from 'prop-types';
import { Blurhash } from 'flavours/glitch/components/blurhash';
import { accountsCountRenderer } from 'flavours/glitch/components/hashtag';
import ShortNumber from 'flavours/glitch/components/short_number';
import Skeleton from 'flavours/glitch/components/skeleton';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import classNames from 'classnames';

export default class Story extends React.PureComponent {

M app/javascript/flavours/glitch/features/favourited_statuses/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/favourites/index.jsx => app/javascript/flavours/glitch/features/favourites/index.jsx +2 -2
@@ 32,13 32,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/flavours/glitch/features/follow_recommendations/components/account.jsx => app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx +1 -1
@@ 5,7 5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { Avatar } from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import Permalink from 'flavours/glitch/components/permalink';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { injectIntl, defineMessages } from 'react-intl';

M app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx => app/javascript/flavours/glitch/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 Permalink from 'flavours/glitch/components/permalink';
import { Avatar } from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';

M app/javascript/flavours/glitch/features/follow_requests/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/followers/index.jsx => app/javascript/flavours/glitch/features/followers/index.jsx +1 -1
@@ 17,7 17,7 @@ import ProfileColumnHeader from 'flavours/glitch/features/account/components/pro
import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';

M app/javascript/flavours/glitch/features/following/index.jsx => app/javascript/flavours/glitch/features/following/index.jsx +1 -1
@@ 17,7 17,7 @@ import ProfileColumnHeader from 'flavours/glitch/features/account/components/pro
import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';

M app/javascript/flavours/glitch/features/getting_started/index.jsx => app/javascript/flavours/glitch/features/getting_started/index.jsx +1 -1
@@ 96,7 96,7 @@ class GettingStarted extends ImmutablePureComponent {
    openSettings: PropTypes.func.isRequired,
  };

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


M app/javascript/flavours/glitch/features/interaction_modal/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/list_adder/components/account.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/list_editor/components/account.jsx => app/javascript/flavours/glitch/features/list_editor/components/account.jsx +1 -1
@@ 3,7 3,7 @@ import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Avatar } from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { defineMessages } from 'react-intl';


M app/javascript/flavours/glitch/features/list_timeline/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/lists/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/mutes/index.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/notifications/components/follow_request.jsx => app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx +1 -1
@@ 2,7 2,7 @@ import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { Avatar } from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import Permalink from 'flavours/glitch/components/permalink';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';

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

const messages = defineMessages({

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

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

M app/javascript/flavours/glitch/features/reblogs/index.jsx => app/javascript/flavours/glitch/features/reblogs/index.jsx +2 -2
@@ 32,13 32,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/flavours/glitch/features/report/components/status_check_box.jsx => app/javascript/flavours/glitch/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 'flavours/glitch/components/status_content';
import { Avatar } from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import Option from './option';
import MediaAttachments from 'flavours/glitch/components/media_attachments';

M app/javascript/flavours/glitch/features/status/components/card.jsx => app/javascript/flavours/glitch/features/status/components/card.jsx +1 -1
@@ 57,7 57,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/flavours/glitch/features/status/components/detailed_status.jsx => app/javascript/flavours/glitch/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 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import StatusContent from 'flavours/glitch/components/status_content';
import MediaGallery from 'flavours/glitch/components/media_gallery';
import AttachmentList from 'flavours/glitch/components/attachment_list';

M app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js => app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js +4 -4
@@ 125,12 125,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/flavours/glitch/features/status/index.jsx => app/javascript/flavours/glitch/features/status/index.jsx +4 -4
@@ 392,12 392,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/flavours/glitch/features/ui/components/actions_modal.jsx => app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx +1 -1
@@ 5,7 5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import StatusContent from 'flavours/glitch/components/status_content';
import { Avatar } from 'flavours/glitch/components/avatar';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import DisplayName from 'flavours/glitch/components/display_name';
import { DisplayName } from 'flavours/glitch/components/display_name';
import classNames from 'classnames';
import { IconButton } from 'flavours/glitch/components/icon_button';


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

M app/javascript/flavours/glitch/features/ui/components/bundle.jsx => app/javascript/flavours/glitch/features/ui/components/bundle.jsx +2 -2
@@ 33,11 33,11 @@ class Bundle extends React.Component {
    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/flavours/glitch/features/ui/components/columns_area.jsx => app/javascript/flavours/glitch/features/ui/components/columns_area.jsx +1 -1
@@ 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';


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

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

M app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx => app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx +1 -1
@@ 4,7 4,7 @@ import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from 'flavours/glitch/actions/compose';
import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose';
import Video, { getPointerPosition } from 'flavours/glitch/features/video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { IconButton } from 'flavours/glitch/components/icon_button';

M app/javascript/flavours/glitch/features/ui/components/header.jsx => app/javascript/flavours/glitch/features/ui/components/header.jsx +3 -3
@@ 52,13 52,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>
        );


@@ 66,8 66,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/flavours/glitch/features/ui/components/media_modal.jsx => app/javascript/flavours/glitch/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 'flavours/glitch/features/video';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
import { IconButton } from 'flavours/glitch/components/icon_button';


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

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

class MediaModal extends ImmutablePureComponent {

  static contextTypes = {


@@ 34,6 29,7 @@ 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,


@@ 135,7 131,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();


@@ 155,7 151,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}


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


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


@@ 258,4 254,4 @@ class MediaModal extends ImmutablePureComponent {

}

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

M app/javascript/flavours/glitch/features/ui/components/onboarding_modal.jsx => app/javascript/flavours/glitch/features/ui/components/onboarding_modal.jsx +1 -1
@@ 184,7 184,7 @@ class OnboardingModal extends React.PureComponent {
    currentIndex: 0,
  };

  componentWillMount() {
  UNSAFE_componentWillMount() {
    const { myAccount, admin, domain, intl } = this.props;
    this.pages = [
      <PageOne key='1' acct={myAccount.get('acct')} domain={domain} />,

M app/javascript/flavours/glitch/features/ui/components/sign_in_banner.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/ui/components/upload_area.jsx => app/javascript/flavours/glitch/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/flavours/glitch/features/ui/containers/status_list_container.js => app/javascript/flavours/glitch/features/ui/containers/status_list_container.js +1 -0
@@ 60,6 60,7 @@ const makeMapStateToProps = () => {

  const mapStateToProps = (state, { timelineId, regex }) => ({
    statusIds: getStatusIds(state, { type: timelineId, regex }),
    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/flavours/glitch/features/ui/index.jsx => app/javascript/flavours/glitch/features/ui/index.jsx +3 -3
@@ 64,7 64,7 @@ import Header from './components/header';

// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import '../../../glitch/components/status';
import "../../components/status";

const messages = defineMessages({
  beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },


@@ 133,7 133,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);


@@ 438,7 438,7 @@ class UI extends React.Component {
    }
  }

  componentWillReceiveProps (nextProps) {
  UNSAFE_componentWillReceiveProps (nextProps) {
    if (nextProps.layout_local_setting !== this.props.layout_local_setting) {
      const layout = layoutFromWindow(nextProps.layout_local_setting);


M app/javascript/flavours/glitch/features/video/index.jsx => app/javascript/flavours/glitch/features/video/index.jsx +2 -2
@@ 373,7 373,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 });
    }


@@ 476,7 476,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/flavours/glitch/is_mobile.ts => app/javascript/flavours/glitch/is_mobile.ts +1 -0
@@ 1,4 1,5 @@
import { supportsPassiveEvents } from 'detect-passive-events';

import { forceSingleColumn } from 'flavours/glitch/initial_state';

const LAYOUT_BREAKPOINT = 630;

M app/javascript/flavours/glitch/main.jsx => app/javascript/flavours/glitch/main.jsx +3 -2
@@ 1,5 1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
import Mastodon from 'flavours/glitch/containers/mastodon';
import { store } from 'flavours/glitch/store';


@@ 18,7 18,8 @@ function main() {
    const mountNode = document.getElementById('mastodon');
    const props = JSON.parse(mountNode.getAttribute('data-props'));

    ReactDOM.render(<Mastodon {...props} />, mountNode);
    const root = createRoot(mountNode);
    root.render(<Mastodon {...props} />);
    store.dispatch(setupBrowserNotifications());

    if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) {

M app/javascript/flavours/glitch/packs/admin.jsx => app/javascript/flavours/glitch/packs/admin.jsx +6 -4
@@ 1,7 1,7 @@
import 'packs/public-path';
import ready from 'flavours/glitch/ready';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

ready(() => {
  [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {


@@ 10,11 10,13 @@ ready(() => {

    import('flavours/glitch/containers/admin_component').then(({ default: AdminComponent }) => {
      return import('flavours/glitch/components/admin/' + componentName).then(({ default: Component }) => {
        ReactDOM.render((
        const root = createRoot(element);

        root.render (
          <AdminComponent locale={locale}>
            <Component {...componentProps} />
          </AdminComponent>
        ), element);
          </AdminComponent>,
        );
      });
    }).catch(error => {
      console.error(error);

M app/javascript/flavours/glitch/packs/common.js => app/javascript/flavours/glitch/packs/common.js +2 -2
@@ 1,9 1,9 @@
import 'packs/public-path';
import { start } from '@rails/ujs';

start();

import 'flavours/glitch/styles/index.scss';

start();

//  This ensures that webpack compiles our images.
require.context('../images', true);

M app/javascript/flavours/glitch/packs/public.jsx => app/javascript/flavours/glitch/packs/public.jsx +3 -2
@@ 11,7 11,7 @@ import { delegate }  from '@rails/ujs';
import emojify  from 'flavours/glitch/features/emoji/emoji';
import { getLocale }  from 'locales';
import React  from 'react';
import ReactDOM  from 'react-dom';
import { createRoot }  from 'react-dom/client';
import { createBrowserHistory }  from 'history';

const messages = defineMessages({


@@ 130,7 130,8 @@ function main() {

          const content = document.createElement('div');

          ReactDOM.render(<MediaContainer locale={locale} components={reactComponents} />, content);
          const root = createRoot(content);
          root.render(<MediaContainer locale={locale} components={reactComponents} />);
          document.body.appendChild(content);
          scrollToDetailedStatus();
        })

M app/javascript/flavours/glitch/packs/share.jsx => app/javascript/flavours/glitch/packs/share.jsx +4 -3
@@ 1,9 1,9 @@
import 'packs/public-path';
import { loadPolyfills } from 'flavours/glitch/polyfills';
import ready from 'flavours/glitch/ready';
import ComposeContainer from 'flavours/glitch/containers/compose_container';
import React from 'react';
import ReactDOM from 'react-dom';
import ready from 'flavours/glitch/ready';
import { createRoot } from 'react-dom/client';

function loaded() {
  const mountNode = document.getElementById('mastodon-compose');


@@ 13,7 13,8 @@ function loaded() {
    if(!attr) return;

    const props = JSON.parse(attr);
    ReactDOM.render(<ComposeContainer {...props} />, mountNode);
    const root = createRoot(mountNode);
    root.render(<ComposeContainer {...props} />);
  }
}


A app/javascript/flavours/glitch/packs/sign_up.js => app/javascript/flavours/glitch/packs/sign_up.js +15 -0
@@ 0,0 1,15 @@
import 'packs/public-path';
import ready from 'flavours/glitch/ready';
import axios from 'axios';

ready(() => {
  setInterval(() => {
    axios.get('/api/v1/emails/check_confirmation').then((response) => {
      if (response.data) {
        window.location = '/start';
      }
    }).catch(error => {
      console.error(error);
    });
  }, 5000);
});

M app/javascript/flavours/glitch/polyfills/base_polyfills.ts => app/javascript/flavours/glitch/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/flavours/glitch/reducers/index.ts => app/javascript/flavours/glitch/reducers/index.ts +35 -34
@@ 1,48 1,49 @@
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 account_notes from './account_notes';
import accounts from './accounts';
import accounts_counters from './accounts_counters';
import statuses from './statuses';
import relationships from './relationships';
import settings from './settings';
import local_settings from './local_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 server from './server';
import boosts from './boosts';
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 pinnedAccountsEditor from './pinned_accounts_editor';
import polls from './polls';
import trends from './trends';
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 local_settings from './local_settings';
import markers from './markers';
import account_notes from './account_notes';
import media_attachments from './media_attachments';
import meta from './meta';
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 pinnedAccountsEditor from './pinned_accounts_editor';
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/flavours/glitch/reducers/markers.js => app/javascript/flavours/glitch/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/flavours/glitch/store/index.ts => app/javascript/flavours/glitch/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/flavours/glitch/store/middlewares/errors.ts => app/javascript/flavours/glitch/store/middlewares/errors.ts +6 -4
@@ 1,17 1,19 @@
import { Middleware } from 'redux';
import type { AnyAction, Middleware } from 'redux';

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

import type { 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/flavours/glitch/store/middlewares/loading_bar.ts => app/javascript/flavours/glitch/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/flavours/glitch/store/middlewares/sounds.ts => app/javascript/flavours/glitch/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/flavours/glitch/styles/components/media.scss => app/javascript/flavours/glitch/styles/components/media.scss +2 -12
@@ 96,13 96,6 @@
    grid-column: span 2;
  }

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

  .full-width & {
    border-radius: 0;
  }


@@ 161,8 154,6 @@
  cursor: zoom-in;
  height: 100%;
  width: 100%;
  position: relative;
  z-index: 1;
  object-fit: contain;
  user-select: none;



@@ 455,6 446,8 @@
  border-radius: 4px;
  box-sizing: border-box;
  color: $white;
  display: flex;
  align-items: center;

  &.editable {
    border-radius: 0;


@@ 497,9 490,6 @@
  &.inline {
    video {
      object-fit: contain;
      position: relative;
      top: 50%;
      transform: translateY(-50%);
    }
  }


M app/javascript/flavours/glitch/theme.yml => app/javascript/flavours/glitch/theme.yml +1 -0
@@ 20,6 20,7 @@ pack:
  modal:
  public: packs/public.jsx
  settings: packs/settings.js
  sign_up: packs/sign_up.js
  share: packs/share.jsx

#  (OPTIONAL) The directory which contains localization files for

M app/javascript/flavours/glitch/types/resources.ts => app/javascript/flavours/glitch/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/javascript/flavours/glitch/utils/dom_helpers.js => app/javascript/flavours/glitch/utils/dom_helpers.js +0 -7
@@ 1,10 1,3 @@
//  Package imports.
import { supportsPassiveEvents } from 'detect-passive-events';

//  This will either be a passive lister options object (if passive
//  events are supported), or `false`.
export const withPassive = supportsPassiveEvents ? { passive: true } : false;

//  Focuses the root element.
export function focusRoot () {
  let e;

M app/javascript/flavours/glitch/utils/resize_image.js => app/javascript/flavours/glitch/utils/resize_image.js +3 -1
@@ 170,7 170,7 @@ const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) =
    .catch(reject);
});

export default inputFile => new Promise((resolve) => {
const resizeFile = (inputFile) => new Promise((resolve) => {
  if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
    resolve(inputFile);
    return;


@@ 187,3 187,5 @@ export default inputFile => new Promise((resolve) => {
      .catch(() => resolve(inputFile));
  }).catch(() => resolve(inputFile));
});

export default resizeFile;

M app/javascript/flavours/glitch/uuid.ts => app/javascript/flavours/glitch/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/flavours/vanilla/theme.yml => app/javascript/flavours/vanilla/theme.yml +1 -0
@@ 20,6 20,7 @@ pack:
  modal:
  public: public.jsx
  settings: public.jsx
  sign_up: sign_up.js
  share: share.jsx

#  (OPTIONAL) The directory which contains localization files for

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

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

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

export default class Dimension extends React.PureComponent {


M app/javascript/mastodon/components/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


M app/javascript/mastodon/components/column.jsx => app/javascript/mastodon/components/column.jsx +6 -4
@@ 3,6 3,8 @@ import PropTypes from 'prop-types';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollTop } from '../scroll';

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;

export default class Column extends React.PureComponent {

  static propTypes = {


@@ 35,17 37,17 @@ export default class Column extends React.PureComponent {

  componentDidMount () {
    if (this.props.bindToDocument) {
      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
      document.addEventListener('wheel', this.handleWheel, listenerOptions);
    } else {
      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
      this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }

  componentWillUnmount () {
    if (this.props.bindToDocument) {
      document.removeEventListener('wheel', this.handleWheel);
      document.removeEventListener('wheel', this.handleWheel, listenerOptions);
    } else {
      this.node.removeEventListener('wheel', this.handleWheel);
      this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }


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);

M app/javascript/mastodon/components/dropdown_menu.jsx => app/javascript/mastodon/components/dropdown_menu.jsx +6 -5
@@ 7,7 7,7 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
import { CircularProgress } from 'mastodon/components/loading_indicator';

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
let id = 0;

class DropdownMenu extends React.PureComponent {


@@ 35,12 35,13 @@ class DropdownMenu extends React.PureComponent {
  handleDocumentClick = e => {
    if (this.node && !this.node.contains(e.target)) {
      this.props.onClose();
      e.stopPropagation();
    }
  };

  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('keydown', this.handleKeyDown, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('keydown', this.handleKeyDown, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);

    if (this.focusedItem && this.props.openedViaKeyboard) {


@@ 49,8 50,8 @@ class DropdownMenu extends React.PureComponent {
  }

  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('keydown', this.handleKeyDown, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }


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

class SilentErrorBoundary extends React.Component {

M app/javascript/mastodon/components/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/scrollable_list.jsx => app/javascript/mastodon/components/scrollable_list.jsx +6 -4
@@ 15,6 15,8 @@ import { connect } from 'react-redux';

const MOUSE_IDLE_DELAY = 300;

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;

const mapStateToProps = (state, { scrollKey }) => {
  return {
    preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),


@@ 237,20 239,20 @@ class ScrollableList extends PureComponent {
  attachScrollListener () {
    if (this.props.bindToDocument) {
      document.addEventListener('scroll', this.handleScroll);
      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : undefined);
      document.addEventListener('wheel', this.handleWheel,  listenerOptions);
    } else {
      this.node.addEventListener('scroll', this.handleScroll);
      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : undefined);
      this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }

  detachScrollListener () {
    if (this.props.bindToDocument) {
      document.removeEventListener('scroll', this.handleScroll);
      document.removeEventListener('wheel', this.handleWheel);
      document.removeEventListener('wheel', this.handleWheel, listenerOptions);
    } else {
      this.node.removeEventListener('scroll', this.handleScroll);
      this.node.removeEventListener('wheel', this.handleWheel);
      this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
    }
  }


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

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

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

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

export default Skeleton;

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

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

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

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) {

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

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

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

export default TimelineHint;

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

import { FormattedMessage } from 'react-intl';

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

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

M app/javascript/mastodon/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 +8 -6
@@ 1,5 1,5 @@
import React, { PureComponent, Fragment } from 'react';
import ReactDOM from 'react-dom';
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl';
import { fromJS } from 'immutable';


@@ 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 = () => {


@@ 94,7 95,7 @@ export default class MediaContainer extends PureComponent {
              }),
            });

            return ReactDOM.createPortal(
            return createPortal(
              <Component {...props} key={`media-${i}`} />,
              component,
            );


@@ 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 +3 -3
@@ 8,10 8,10 @@ import LinkFooter from 'mastodon/features/ui/components/link_footer';
import { Helmet } from 'react-helmet';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
import Account from 'mastodon/containers/account_container';
import Skeleton from 'mastodon/components/skeleton';
import { Skeleton } from 'mastodon/components/skeleton';
import { Icon }  from 'mastodon/components/icon';
import classNames from 'classnames';
import { 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 +2 -3
@@ 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';


@@ 12,9 12,8 @@ import ColumnBackButton from '../../components/column_back_button';
import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import TimelineHint from 'mastodon/components/timeline_hint';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import { me } from 'mastodon/initial_state';
import { 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 +6 -6
@@ 27,7 27,7 @@ const messages = defineMessages({

let EmojiPicker, Emoji; // load asynchronously

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;

const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;



@@ 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 {


@@ 78,12 78,12 @@ class ModifierPickerMenu extends React.PureComponent {
  };

  attachListeners () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }

  removeListeners () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }



@@ 176,7 176,7 @@ class EmojiPickerMenuImpl extends React.PureComponent {
  };

  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);

    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need


@@ 191,7 191,7 @@ class EmojiPickerMenuImpl extends React.PureComponent {
  }

  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }


M app/javascript/mastodon/features/compose/components/language_dropdown.jsx => app/javascript/mastodon/features/compose/components/language_dropdown.jsx +4 -3
@@ 15,7 15,7 @@ const messages = defineMessages({
  clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;

class LanguageDropdownMenu extends React.PureComponent {



@@ 39,11 39,12 @@ class LanguageDropdownMenu extends React.PureComponent {
  handleDocumentClick = e => {
    if (this.node && !this.node.contains(e.target)) {
      this.props.onClose();
      e.stopPropagation();
    }
  };

  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);

    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need


@@ 57,7 58,7 @@ class LanguageDropdownMenu extends React.PureComponent {
  }

  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }


M app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx => app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +5 -4
@@ 19,7 19,7 @@ const messages = defineMessages({
  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
});

const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;

class PrivacyDropdownMenu extends React.PureComponent {



@@ 34,6 34,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
  handleDocumentClick = e => {
    if (this.node && !this.node.contains(e.target)) {
      this.props.onClose();
      e.stopPropagation();
    }
  };



@@ 91,13 92,13 @@ class PrivacyDropdownMenu extends React.PureComponent {
  };

  componentDidMount () {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('click', this.handleDocumentClick, { capture: true });
    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
    if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
  }

  componentWillUnmount () {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('click', this.handleDocumentClick, { capture: true });
    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  }



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

export default class Story extends React.PureComponent {

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

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

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

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

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

M app/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/main.jsx => app/javascript/mastodon/main.jsx +3 -2
@@ 1,5 1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
import Mastodon from 'mastodon/containers/mastodon';
import { store } from 'mastodon/store';


@@ 17,7 17,8 @@ function main() {
    const mountNode = document.getElementById('mastodon');
    const props = JSON.parse(mountNode.getAttribute('data-props'));

    ReactDOM.render(<Mastodon {...props} />, mountNode);
    const root = createRoot(mountNode);
    root.render(<Mastodon {...props} />);
    store.dispatch(setupBrowserNotifications());

    if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) {

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/utils/__tests__/html-test.js => app/javascript/mastodon/utils/__tests__/html-test.js +1 -1
@@ 1,7 1,7 @@
import * as html from '../html';

describe('html', () => {
  describe('unsecapeHTML', () => {
  describe('unescapeHTML', () => {
    it('returns unescaped HTML', () => {
      const output = html.unescapeHTML('<p>lorem</p><p>ipsum</p><br>&lt;br&gt;');
      expect(output).toEqual('lorem\n\nipsum\n<br>');

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/packs/admin.jsx => app/javascript/packs/admin.jsx +6 -4
@@ 1,7 1,7 @@
import './public-path';
import ready from '../mastodon/ready';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

ready(() => {
  [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {


@@ 10,11 10,13 @@ ready(() => {

    import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
      return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
        ReactDOM.render((
        const root = createRoot(element);

        root.render (
          <AdminComponent locale={locale}>
            <Component {...componentProps} />
          </AdminComponent>
        ), element);
          </AdminComponent>,
        );
      });
    }).catch(error => {
      console.error(error);

M app/javascript/packs/public.jsx => app/javascript/packs/public.jsx +3 -2
@@ 15,7 15,7 @@ import { delegate }  from '@rails/ujs';
import emojify  from '../mastodon/features/emoji/emoji';
import { getLocale }  from '../mastodon/locales';
import React  from 'react';
import ReactDOM  from 'react-dom';
import { createRoot }  from 'react-dom/client';
import { createBrowserHistory }  from 'history';

start();


@@ 137,7 137,8 @@ function loaded() {

          const content = document.createElement('div');

          ReactDOM.render(<MediaContainer locale={locale} components={reactComponents} />, content);
          const root = createRoot(content);
          root.render(<MediaContainer locale={locale} components={reactComponents} />);
          document.body.appendChild(content);
          scrollToDetailedStatus();
        })

M app/javascript/packs/share.jsx => app/javascript/packs/share.jsx +3 -2
@@ 4,7 4,7 @@ import { start } from '../mastodon/common';
import ready from '../mastodon/ready';
import ComposeContainer  from '../mastodon/containers/compose_container';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

start();



@@ 16,7 16,8 @@ function loaded() {
    if(!attr) return;

    const props = JSON.parse(attr);
    ReactDOM.render(<ComposeContainer {...props} />, mountNode);
    const root = createRoot(mountNode);
    root.render(<ComposeContainer {...props} />);
  }
}


A app/javascript/packs/sign_up.js => app/javascript/packs/sign_up.js +15 -0
@@ 0,0 1,15 @@
import './public-path';
import ready from '../mastodon/ready';
import axios from 'axios';

ready(() => {
  setInterval(() => {
    axios.get('/api/v1/emails/check_confirmation').then((response) => {
      if (response.data) {
        window.location = '/start';
      }
    }).catch(error => {
      console.error(error);
    });
  }, 5000);
});

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/styles/mastodon/forms.scss => app/javascript/styles/mastodon/forms.scss +8 -0
@@ 136,6 136,10 @@ code {
    line-height: 22px;
    color: $secondary-text-color;
    margin-bottom: 30px;

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

  .rules-list {


@@ 1039,6 1043,10 @@ code {
  }
}

.simple_form .h-captcha {
  text-align: center;
}

.permissions-list {
  &__item {
    padding: 15px;

M app/javascript/types/image.d.ts => app/javascript/types/image.d.ts +0 -5
@@ 14,11 14,6 @@ declare module '*.jpg' {
  export default path;
}

declare module '*.jpg' {
  const path: string;
  export default path;
}

declare module '*.png' {
  const path: string;
  export default path;

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/activitypub/activity/flag.rb => app/lib/activitypub/activity/flag.rb +5 -1
@@ 16,7 16,7 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
        @account,
        target_account,
        status_ids: target_statuses.nil? ? [] : target_statuses.map(&:id),
        comment: @json['content'] || '',
        comment: report_comment,
        uri: report_uri
      )
    end


@@ 35,4 35,8 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
  def report_uri
    @json['id'] unless @json['id'].nil? || non_matching_uri_hosts?(@account.uri, @json['id'])
  end

  def report_comment
    (@json['content'] || '')[0...5000]
  end
end

M app/lib/activitypub/tag_manager.rb => app/lib/activitypub/tag_manager.rb +4 -0
@@ 28,6 28,8 @@ class ActivityPub::TagManager
      return activity_account_status_url(target.account, target) if target.reblog?

      short_account_status_url(target.account, target)
    when :flag
      target.uri
    end
  end



@@ 43,6 45,8 @@ class ActivityPub::TagManager
      account_status_url(target.account, target)
    when :emoji
      emoji_url(target)
    when :flag
      target.uri
    end
  end


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

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

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

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

M app/lib/application_extension.rb => app/lib/application_extension.rb +0 -4
@@ 9,10 9,6 @@ module ApplicationExtension
    validates :redirect_uri, length: { maximum: 2_000 }
  end

  def most_recently_used_access_token
    @most_recently_used_access_token ||= access_tokens.where.not(last_used_at: nil).order(last_used_at: :desc).first
  end

  def confirmation_redirect_uri
    redirect_uri.lines.first.strip
  end

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

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

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

    target_statuses = statuses.select do |status|


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

    target_statuses = statuses.select do |status|


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

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

M app/lib/link_details_extractor.rb => app/lib/link_details_extractor.rb +1 -1
@@ 140,7 140,7 @@ class LinkDetailsExtractor
  end

  def html
    player_url.present? ? content_tag(:iframe, nil, src: player_url, width: width, height: height, allowtransparency: 'true', scrolling: 'no', frameborder: '0') : nil
    player_url.present? ? content_tag(:iframe, nil, src: player_url, width: width, height: height, allowfullscreen: 'true', allowtransparency: 'true', scrolling: 'no', frameborder: '0') : nil
  end

  def width

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/account.rb => app/models/account.rb +2 -2
@@ 299,11 299,11 @@ class Account < ApplicationRecord
  end

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

  def fields_attributes=(attributes)

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

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

  def validate_local_account

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

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

      next if username.blank?

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

  def setting

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

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

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

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

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

M app/models/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/models/form/admin_settings.rb => app/models/form/admin_settings.rb +1 -0
@@ 41,6 41,7 @@ class Form::AdminSettings
    content_cache_retention_period
    backups_retention_period
    status_page_url
    captcha_enabled
  ).freeze

  INTEGER_KEYS = %i(

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

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


M app/models/report.rb => app/models/report.rb +4 -5
@@ 40,7 40,10 @@ class Report < ApplicationRecord
  scope :resolved,   -> { where.not(action_taken_at: nil) }
  scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }

  validates :comment, length: { maximum: 1_000 }
  # A report is considered local if the reporter is local
  delegate :local?, to: :account

  validates :comment, length: { maximum: 1_000 }, if: :local?
  validates :rule_ids, absence: true, unless: :violation?

  validate :validate_rule_ids


@@ 51,10 54,6 @@ class Report < ApplicationRecord
    violation: 2_000,
  }

  def local?
    false # Force uri_for to use uri attribute
  end

  before_validation :set_uri, only: :create

  after_create_commit :trigger_webhooks

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

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

  def can?(*any_of_privileges)

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

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

  def generate_secret

M app/services/backup_service.rb => app/services/backup_service.rb +2 -2
@@ 101,8 101,8 @@ class BackupService < BaseService
    actor[:likes]       = 'likes.json'
    actor[:bookmarks]   = 'bookmarks.json'

    download_to_zip(tar, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists?
    download_to_zip(tar, account.header, "header#{File.extname(account.header.path)}") if account.header.exists?
    download_to_zip(zipfile, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists?
    download_to_zip(zipfile, account.header, "header#{File.extname(account.header.path)}") if account.header.exists?

    json = Oj.dump(actor)


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

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

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

      next if username.blank?

      [str, username, domain]
    end.compact
    end

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

M app/validators/vote_validator.rb => app/validators/vote_validator.rb +5 -1
@@ 3,8 3,8 @@
class VoteValidator < ActiveModel::Validator
  def validate(vote)
    vote.errors.add(:base, I18n.t('polls.errors.expired')) if vote.poll_expired?

    vote.errors.add(:base, I18n.t('polls.errors.invalid_choice')) if invalid_choice?(vote)
    vote.errors.add(:base, I18n.t('polls.errors.self_vote')) if self_vote?(vote)

    vote.errors.add(:base, I18n.t('polls.errors.already_voted')) if additional_voting_not_allowed?(vote)
  end


@@ 27,6 27,10 @@ class VoteValidator < ActiveModel::Validator
    vote.choice.negative? || vote.choice >= vote.poll.options.size
  end

  def self_vote?(vote)
    vote.account_id == vote.poll.account_id
  end

  def already_voted_for_same_choice_on_multiple_poll?(vote)
    if vote.persisted?
      account_votes_on_same_poll(vote).where(choice: vote.choice).where.not(poll_votes: { id: vote }).exists?

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 app/views/admin/settings/registrations/show.html.haml => app/views/admin/settings/registrations/show.html.haml +1 -1
@@ 19,7 19,7 @@

  - if captcha_available?
    .fields-group
      = f.input :captcha_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.captcha_enabled.title'), hint: t('admin.settings.captcha_enabled.desc_html'), glitch_only: true
      = f.input :captcha_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.captcha_enabled.title'), hint: t('admin.settings.captcha_enabled.desc_html')

  .fields-group
    = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, input_html: { rows: 2 }

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

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

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


M app/views/oauth/authorized_applications/index.html.haml => app/views/oauth/authorized_applications/index.html.haml +2 -2
@@ 18,8 18,8 @@

      .announcements-list__item__action-bar
        .announcements-list__item__meta
          - if application.most_recently_used_access_token
            = t('doorkeeper.authorized_applications.index.last_used_at', date: l(application.most_recently_used_access_token.last_used_at.to_date))
          - if @last_used_at_by_app[application.id]
            = t('doorkeeper.authorized_applications.index.last_used_at', date: l(@last_used_at_by_app[application.id].to_date))
          - else
            = t('doorkeeper.authorized_applications.index.never_used')


M app/workers/post_process_media_worker.rb => app/workers/post_process_media_worker.rb +1 -1
@@ 24,7 24,7 @@ class PostProcessMediaWorker
    media_attachment.processing = :in_progress
    media_attachment.save

    # Because paperclip-av-transcover overwrites this attribute
    # Because paperclip-av-transcoder overwrites this attribute
    # we will save it here and restore it after reprocess is done
    previous_meta = media_attachment.file_meta


M config/initializers/ffmpeg.rb => config/initializers/ffmpeg.rb +1 -1
@@ 1,3 1,3 @@
if ENV['FFMPEG_BINARY'].present?
    FFMPEG.ffmpeg_binary = ENV['FFMPEG_BINARY']
  FFMPEG.ffmpeg_binary = ENV['FFMPEG_BINARY']
end

M config/initializers/omniauth.rb => config/initializers/omniauth.rb +1 -1
@@ 73,7 73,7 @@ Devise.setup do |config|
    oidc_options[:display_name] = ENV['OIDC_DISPLAY_NAME'] #OPTIONAL
    oidc_options[:issuer] = ENV['OIDC_ISSUER'] if ENV['OIDC_ISSUER'] #NEED
    oidc_options[:discovery] = ENV['OIDC_DISCOVERY'] == 'true' if ENV['OIDC_DISCOVERY'] #OPTIONAL (default: false)
    oidc_options[:client_auth_method] =  ENV['OIDC_CLIENT_AUTH_METHOD'] if ENV['OIDC_CLIENT_AUTH_METHOD'] #OPTIONAL (default: basic)
    oidc_options[:client_auth_method] = ENV['OIDC_CLIENT_AUTH_METHOD'] if ENV['OIDC_CLIENT_AUTH_METHOD'] #OPTIONAL (default: basic)
    scope_string = ENV['OIDC_SCOPE'] if ENV['OIDC_SCOPE'] #NEED
    scopes = scope_string.split(',')
    oidc_options[:scope] = scopes.map { |x| x.to_sym }

M config/initializers/paperclip.rb => config/initializers/paperclip.rb +4 -4
@@ 61,13 61,13 @@ if ENV['S3_ENABLED'] == 'true'

    s3_options: {
      signature_version: ENV.fetch('S3_SIGNATURE_VERSION') { 'v4' },
      http_open_timeout: ENV.fetch('S3_OPEN_TIMEOUT'){ '5' }.to_i,
      http_read_timeout: ENV.fetch('S3_READ_TIMEOUT'){ '5' }.to_i,
      http_open_timeout: ENV.fetch('S3_OPEN_TIMEOUT') { '5' }.to_i,
      http_read_timeout: ENV.fetch('S3_READ_TIMEOUT') { '5' }.to_i,
      http_idle_timeout: 5,
      retry_limit: 0,
    }
  )
  

  Paperclip::Attachment.default_options[:s3_permissions] = ->(*) { nil } if ENV['S3_PERMISSION'] == ''

  if ENV.has_key?('S3_ENDPOINT')


@@ 124,7 124,7 @@ elsif ENV['SWIFT_ENABLED'] == 'true'
      openstack_cache_ttl: ENV.fetch('SWIFT_CACHE_TTL') { 60 },
      openstack_temp_url_key: ENV['SWIFT_TEMP_URL_KEY'],
    },
    

    fog_file: { 'Cache-Control' => 'public, max-age=315576000, immutable' },

    fog_directory: ENV['SWIFT_CONTAINER'],

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/initializers/webauthn.rb => config/initializers/webauthn.rb +1 -1
@@ 1,7 1,7 @@
WebAuthn.configure do |config|
  # This value needs to match `window.location.origin` evaluated by
  # the User Agent during registration and authentication ceremonies.
  config.origin = "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}"
  config.origin = "#{Rails.configuration.x.use_https ? 'https' : 'http'}://#{Rails.configuration.x.web_domain}"

  # Relying Party name for display purposes
  config.rp_name = "Mastodon"

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 +10 -3
@@ 731,6 731,9 @@ en:
      branding:
        preamble: Your server's branding differentiates it from other servers in the network. This information may be displayed across a variety of environments, such as Mastodon's web interface, native applications, in link previews on other websites and within messaging apps, and so on. For this reason, it is best to keep this information clear, short and concise.
        title: Branding
      captcha_enabled:
        desc_html: This relies on external scripts from hCaptcha, which may be a security and privacy concern. In addition, <strong>this can make the registration process significantly less accessible to some (especially disabled) people</strong>. For these reasons, please consider alternative measures such as approval-based or invite-based registration.
        title: Require new users to solve a CAPTCHA to confirm their account
      content_retention:
        preamble: Control how user-generated content is stored in Mastodon.
        title: Content retention


@@ 979,6 982,9 @@ en:
    your_token: Your access token
  auth:
    apply_for_account: Request an account
    captcha_confirmation:
      hint_html: Just one more step! To confirm your account, this server requires you to solve a CAPTCHA. You can <a href="/about/more">contact the server administrator</a> if you have questions or need assistance with confirming your account.
      title: User verification
    change_password: Password
    confirmations:
      wrong_email_hint: If that e-mail address is not correct, you can change it in account settings.


@@ 1027,8 1033,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.


@@ 1440,6 1446,7 @@ en:
      expired: The poll has already ended
      invalid_choice: The chosen vote option does not exist
      over_character_limit: cannot be longer than %{max} characters each
      self_vote: You cannot vote in your own polls
      too_few_options: must have more than one item
      too_many_options: can't contain more than %{max} items
  preferences:


@@ 1595,7 1602,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/routes/api.rb => config/routes/api.rb +1 -0
@@ 110,6 110,7 @@ namespace :api, format: false do

    namespace :emails do
      resources :confirmations, only: [:create]
      get :check_confirmation, to: 'confirmations#check'
    end

    resource :instance, only: [:show] do

M config/settings.yml => config/settings.yml +1 -1
@@ 43,8 43,8 @@ defaults: &defaults
  show_domain_blocks_rationale: 'disabled'
  outgoing_spoilers: ''
  require_invite_text: false
  captcha_enabled: false
  backups_retention_period: 7
  captcha_enabled: false

development:
  <<: *defaults

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

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

    UnavailableDomain.delete_all


M jest.config.js => jest.config.js +0 -1
@@ 10,7 10,6 @@ const config = {
    '<rootDir>/tmp/',
    '<rootDir>/app/javascript/themes/',
  ],
  setupFiles: ['raf/polyfill'],
  setupFilesAfterEnv: ['<rootDir>/app/javascript/mastodon/test_setup.js'],
  collectCoverageFrom: [
    'app/javascript/mastodon/**/*.{js,jsx,ts,tsx}',

M lib/mastodon/media_cli.rb => lib/mastodon/media_cli.rb +1 -1
@@ 24,7 24,7 @@ module Mastodon
    desc 'remove', 'Remove remote media files, headers or avatars'
    long_desc <<-DESC
      Removes locally cached copies of media attachments (and optionally profile
      headers and avatars) from other servers. By default, only media attachements
      headers and avatars) from other servers. By default, only media attachments
      are removed.
      The --days option specifies how old media attachments have to be before
      they are removed. In case of avatars and headers, it specifies how old

M lib/tasks/tests.rake => lib/tasks/tests.rake +1 -1
@@ 25,7 25,7 @@ namespace :tests do
      end

      if Account.where(domain: Rails.configuration.x.local_domain).exists?
        puts 'Faux remote accounts not properly claned up'
        puts 'Faux remote accounts not properly cleaned up'
        exit(1)
      end


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


@@ 76,22 76,22 @@
    "intl-messageformat": "^2.2.0",
    "intl-relativeformat": "^6.4.3",
    "js-yaml": "^4.1.0",
    "jsdom": "^21.1.2",
    "jsdom": "^22.0.0",
    "lodash": "^4.17.21",
    "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",
    "punycode": "^2.3.0",
    "react": "^16.14.0",
    "react-dom": "^16.14.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-helmet": "^6.1.0",
    "react-hotkeys": "^1.1.4",
    "react-immutable-proptypes": "^2.2.0",


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


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


@@ 143,7 143,7 @@
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^12.1.5",
    "@testing-library/react": "^14.0.0",
    "@types/babel__core": "^7.20.0",
    "@types/emoji-mart": "^3.0.9",
    "@types/escape-html": "^1.0.2",


@@ 158,9 158,8 @@
    "@types/pg": "^8.6.6",
    "@types/prop-types": "^15.7.5",
    "@types/punycode": "^2.1.0",
    "@types/raf": "^3.4.0",
    "@types/react": "^16.14.38",
    "@types/react-dom": "^16.9.18",
    "@types/react": "^18.0.26",
    "@types/react-dom": "^18.2.4",
    "@types/react-helmet": "^6.1.6",
    "@types/react-immutable-proptypes": "^2.1.0",
    "@types/react-intl": "2.3.18",


@@ 179,14 178,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.7",
    "@typescript-eslint/parser": "^5.59.7",
    "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",


@@ 197,16 197,16 @@
    "jest-environment-jsdom": "^29.5.0",
    "lint-staged": "^13.2.2",
    "prettier": "^2.8.8",
    "raf": "^3.4.1",
    "react-intl-translations-manager": "^5.0.3",
    "react-test-renderer": "^16.14.0",
    "stylelint": "^15.6.1",
    "react-test-renderer": "^18.2.0",
    "stylelint": "^15.6.2",
    "stylelint-config-standard-scss": "^9.0.0",
    "typescript": "^5.0.4",
    "webpack-dev-server": "^3.11.3",
    "yargs": "^17.7.2"
  },
  "resolutions": {
    "@types/react": "^18.0.26",
    "kind-of": "^6.0.3",
    "webpack/terser-webpack-plugin": "^4.2.3"
  },


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

M spec/controllers/admin/confirmations_controller_spec.rb => spec/controllers/admin/confirmations_controller_spec.rb +1 -1
@@ 32,7 32,7 @@ RSpec.describe Admin::ConfirmationsController do
    end
  end

  describe 'POST #resernd' do
  describe 'POST #resend' do
    subject { post :resend, params: { account_id: user.account.id } }

    let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) }

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 +339 -4
@@ 5,19 5,354 @@ require 'rails_helper'
describe Api::V1::Admin::CanonicalEmailBlocksController do
  render_views

  let(:user)    { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'admin:read') }
  let(:account) { Fabricate(:account) }
  let(:role)    { UserRole.find_by(name: 'Admin') }
  let(:user)    { Fabricate(:user, role: role) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:scopes)  { 'admin:read:canonical_email_blocks admin:write:canonical_email_blocks' }

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

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

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

  shared_examples 'forbidden for wrong role' do |wrong_role|
    let(:role) { UserRole.find_by(name: wrong_role) }

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

  describe 'GET #index' do
    context 'with wrong scope' do
      before do
        get :index
      end

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

    context 'with wrong role' do
      before do
        get :index
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

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

      expect(response).to have_http_status(200)
    end

    context 'when there is no canonical email block' do
      it 'returns an empty list' do
        get :index

        body = body_as_json

        expect(body).to be_empty
      end
    end

    context 'when there are canonical email blocks' do
      let!(:canonical_email_blocks) { Fabricate.times(5, :canonical_email_block) }
      let(:expected_email_hashes) { canonical_email_blocks.pluck(:canonical_email_hash) }

      it 'returns the correct canonical email hashes' do
        get :index

        json = body_as_json

        expect(json.pluck(:canonical_email_hash)).to match_array(expected_email_hashes)
      end

      context 'with limit param' do
        let(:params) { { limit: 2 } }

        it 'returns only the requested number of canonical email blocks' do
          get :index, params: params

          json = body_as_json

          expect(json.size).to eq(params[:limit])
        end
      end

      context 'with since_id param' do
        let(:params) { { since_id: canonical_email_blocks[1].id } }

        it 'returns only the canonical email blocks after since_id' do
          get :index, params: params

          canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s)
          json = body_as_json

          expect(json.pluck(:id)).to match_array(canonical_email_blocks_ids[2..])
        end
      end

      context 'with max_id param' do
        let(:params) { { max_id: canonical_email_blocks[3].id } }

        it 'returns only the canonical email blocks before max_id' do
          get :index, params: params

          canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s)
          json = body_as_json

          expect(json.pluck(:id)).to match_array(canonical_email_blocks_ids[..2])
        end
      end
    end
  end

  describe 'GET #show' do
    let!(:canonical_email_block) { Fabricate(:canonical_email_block) }
    let(:params) { { id: canonical_email_block.id } }

    context 'with wrong scope' do
      before do
        get :show, params: params
      end

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

    context 'with wrong role' do
      before do
        get :show, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    context 'when canonical email block exists' do
      it 'returns http success' do
        get :show, params: params

        expect(response).to have_http_status(200)
      end

      it 'returns canonical email block data correctly' do
        get :show, params: params

        json = body_as_json

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

    context 'when canonical block does not exist' do
      it 'returns http not found' do
        get :show, params: { id: 0 }

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

  describe 'POST #test' do
    context 'with wrong scope' do
      before do
        post :test
      end

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

    context 'with wrong role' do
      before do
        post :test, params: { email: 'whatever@email.com' }
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    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

  describe 'POST #create' do
    let(:params) { { email: 'example@email.com' } }
    let(:canonical_email_block) { CanonicalEmailBlock.new(email: params[:email]) }

    context 'with wrong scope' do
      before do
        post :create, params: params
      end

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

    context 'with wrong role' do
      before do
        post :create, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

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

      expect(response).to have_http_status(200)
    end

    it 'returns canonical_email_hash correctly' do
      post :create, params: params

      json = body_as_json

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

    context 'when required email param is not provided' do
      it 'returns http unprocessable entity' do
        post :create

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

    context 'when canonical_email_hash param is provided instead of email' do
      let(:params) { { canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } }

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

        expect(response).to have_http_status(200)
      end

      it 'returns correct canonical_email_hash' do
        post :create, params: params

        json = body_as_json

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

    context 'when both email and canonical_email_hash params are provided' do
      let(:params) { { email: 'example@email.com', canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } }

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

        expect(response).to have_http_status(200)
      end

      it 'ignores canonical_email_hash param' do
        post :create, params: params

        json = body_as_json

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

    context 'when canonical email was already blocked' do
      before do
        canonical_email_block.save
      end

      it 'returns http unprocessable entity' do
        post :create, params: params

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

  describe 'DELETE #destroy' do
    let!(:canonical_email_block) { Fabricate(:canonical_email_block) }
    let(:params) { { id: canonical_email_block.id } }

    context 'with wrong scope' do
      before do
        delete :destroy, params: params
      end

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

    context 'with wrong role' do
      before do
        delete :destroy, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    it 'returns http success' do
      delete :destroy, params: params

      expect(response).to have_http_status(200)
    end

    context 'when canonical email block is not found' do
      it 'returns http not found' do
        delete :destroy, params: { id: 0 }

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

M spec/controllers/api/v1/admin/domain_allows_controller_spec.rb => spec/controllers/api/v1/admin/domain_allows_controller_spec.rb +8 -0
@@ 128,5 128,13 @@ RSpec.describe Api::V1::Admin::DomainAllowsController do
        expect(response).to have_http_status(422)
      end
    end

    context 'when domain name is not specified' do
      it 'returns http unprocessable entity' do
        post :create

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

M spec/controllers/api/v1/admin/email_domain_blocks_controller_spec.rb => spec/controllers/api/v1/admin/email_domain_blocks_controller_spec.rb +264 -3
@@ 5,19 5,280 @@ require 'rails_helper'
describe Api::V1::Admin::EmailDomainBlocksController do
  render_views

  let(:user)    { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'admin:read') }
  let(:role)    { UserRole.find_by(name: 'Admin') }
  let(:user)    { Fabricate(:user, role: role) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:account) { Fabricate(:account) }
  let(:scopes)  { 'admin:read:email_domain_blocks admin:write:email_domain_blocks' }

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

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

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

  shared_examples 'forbidden for wrong role' do |wrong_role|
    let(:role) { UserRole.find_by(name: wrong_role) }

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

  describe 'GET #index' do
    context 'with wrong scope' do
      before do
        get :index
      end

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

    context 'with wrong role' do
      before do
        get :index
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

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

      expect(response).to have_http_status(200)
    end

    context 'when there is no email domain block' do
      it 'returns an empty list' do
        get :index

        json = body_as_json

        expect(json).to be_empty
      end
    end

    context 'when there are email domain blocks' do
      let!(:email_domain_blocks) { Fabricate.times(5, :email_domain_block) }
      let(:blocked_email_domains) { email_domain_blocks.pluck(:domain) }

      it 'return the correct blocked email domains' do
        get :index

        json = body_as_json

        expect(json.pluck(:domain)).to match_array(blocked_email_domains)
      end

      context 'with limit param' do
        let(:params) { { limit: 2 } }

        it 'returns only the requested number of email domain blocks' do
          get :index, params: params

          json = body_as_json

          expect(json.size).to eq(params[:limit])
        end
      end

      context 'with since_id param' do
        let(:params) { { since_id: email_domain_blocks[1].id } }

        it 'returns only the email domain blocks after since_id' do
          get :index, params: params

          email_domain_blocks_ids = email_domain_blocks.pluck(:id).map(&:to_s)
          json = body_as_json

          expect(json.pluck(:id)).to match_array(email_domain_blocks_ids[2..])
        end
      end

      context 'with max_id param' do
        let(:params) { { max_id: email_domain_blocks[3].id } }

        it 'returns only the email domain blocks before max_id' do
          get :index, params: params

          email_domain_blocks_ids = email_domain_blocks.pluck(:id).map(&:to_s)
          json = body_as_json

          expect(json.pluck(:id)).to match_array(email_domain_blocks_ids[..2])
        end
      end
    end
  end

  describe 'GET #show' do
    let!(:email_domain_block) { Fabricate(:email_domain_block) }
    let(:params) { { id: email_domain_block.id } }

    context 'with wrong scope' do
      before do
        get :show, params: params
      end

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

    context 'with wrong role' do
      before do
        get :show, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    context 'when email domain block exists' do
      it 'returns http success' do
        get :show, params: params

        expect(response).to have_http_status(200)
      end

      it 'returns the correct blocked domain' do
        get :show, params: params

        json = body_as_json

        expect(json[:domain]).to eq(email_domain_block.domain)
      end
    end

    context 'when email domain block does not exist' do
      it 'returns http not found' do
        get :show, params: { id: 0 }

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

  describe 'POST #create' do
    let(:params) { { domain: 'example.com' } }

    context 'with wrong scope' do
      before do
        post :create, params: params
      end

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

    context 'with wrong role' do
      before do
        post :create, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

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

      expect(response).to have_http_status(200)
    end

    it 'returns the correct blocked email domain' do
      post :create, params: params

      json = body_as_json

      expect(json[:domain]).to eq(params[:domain])
    end

    context 'when domain param is not provided' do
      let(:params) { { domain: '' } }

      it 'returns http unprocessable entity' do
        post :create, params: params

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

    context 'when provided domain name has an invalid character' do
      let(:params) { { domain: 'do\uD800.com' } }

      it 'returns http unprocessable entity' do
        post :create, params: params

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

    context 'when provided domain is already blocked' do
      before do
        EmailDomainBlock.create(params)
      end

      it 'returns http unprocessable entity' do
        post :create, params: params

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

  describe 'DELETE #destroy' do
    let!(:email_domain_block) { Fabricate(:email_domain_block) }
    let(:params) { { id: email_domain_block.id } }

    context 'with wrong scope' do
      before do
        delete :destroy, params: params
      end

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

    context 'with wrong role' do
      before do
        delete :destroy, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    it 'returns http success' do
      delete :destroy, params: params

      expect(response).to have_http_status(200)
    end

    it 'returns an empty body' do
      delete :destroy, params: params

      json = body_as_json

      expect(json).to be_empty
    end

    it 'deletes email domain block' do
      delete :destroy, params: params

      email_domain_block = EmailDomainBlock.find_by(id: params[:id])

      expect(email_domain_block).to be_nil
    end

    context 'when email domain block does not exist' do
      it 'returns http not found' do
        delete :destroy, params: { id: 0 }

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

M spec/controllers/api/v1/admin/ip_blocks_controller_spec.rb => spec/controllers/api/v1/admin/ip_blocks_controller_spec.rb +290 -4
@@ 5,19 5,305 @@ require 'rails_helper'
describe Api::V1::Admin::IpBlocksController do
  render_views

  let(:user)    { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'admin:read') }
  let(:account) { Fabricate(:account) }
  let(:role)    { UserRole.find_by(name: 'Admin') }
  let(:user)    { Fabricate(:user, role: role) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:scopes)  { 'admin:read:ip_blocks admin:write:ip_blocks' }

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

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

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

  shared_examples 'forbidden for wrong role' do |wrong_role|
    let(:role) { UserRole.find_by(name: wrong_role) }

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

  describe 'GET #index' do
    context 'with wrong scope' do
      before do
        get :index
      end

      it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks'
    end

    context 'with wrong role' do
      before do
        get :index
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    it 'returns http success' do
      get :index

      expect(response).to have_http_status(200)
    end

    context 'when there is no ip block' do
      it 'returns an empty body' do
        get :index

        json = body_as_json

        expect(json).to be_empty
      end
    end

    context 'when there are ip blocks' do
      let!(:ip_blocks) do
        [
          IpBlock.create(ip: '192.0.2.0/24', severity: :no_access),
          IpBlock.create(ip: '172.16.0.1', severity: :sign_up_requires_approval, comment: 'Spam'),
          IpBlock.create(ip: '2001:0db8::/32', severity: :sign_up_block, expires_in: 10.days),
        ]
      end
      let(:expected_response) do
        ip_blocks.map do |ip_block|
          {
            id: ip_block.id.to_s,
            ip: ip_block.ip,
            severity: ip_block.severity.to_s,
            comment: ip_block.comment,
            created_at: ip_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
            expires_at: ip_block.expires_at&.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
          }
        end
      end

      it 'returns the correct blocked ips' do
        get :index

        json = body_as_json

        expect(json).to match_array(expected_response)
      end

      context 'with limit param' do
        let(:params) { { limit: 2 } }

        it 'returns only the requested number of ip blocks' do
          get :index, params: params

          json = body_as_json

          expect(json.size).to eq(params[:limit])
        end
      end
    end
  end

  describe 'GET #show' do
    let!(:ip_block) { IpBlock.create(ip: '192.0.2.0/24', severity: :no_access) }
    let(:params) { { id: ip_block.id } }

    context 'with wrong scope' do
      before do
        get :show, params: params
      end

      it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks'
    end

    context 'with wrong role' do
      before do
        get :show, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    it 'returns http success' do
      get :show, params: params

      expect(response).to have_http_status(200)
    end

    it 'returns the correct ip block' do
      get :show, params: params

      json = body_as_json

      expect(json[:ip]).to eq("#{ip_block.ip}/#{ip_block.ip.prefix}")
      expect(json[:severity]).to eq(ip_block.severity.to_s)
    end

    context 'when ip block does not exist' do
      it 'returns http not found' do
        get :show, params: { id: 0 }

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

  describe 'POST #create' do
    let(:params) { { ip: '151.0.32.55', severity: 'no_access', comment: 'Spam' } }

    context 'with wrong scope' do
      before do
        post :create, params: params
      end

      it_behaves_like 'forbidden for wrong scope', 'admin:read:ip_blocks'
    end

    context 'with wrong role' do
      before do
        post :create, params: params
      end

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end

    it 'returns http success' do
      get :index, params: { account_id: account.id, limit: 2 }
      post :create, params: params

      expect(response).to have_http_status(200)
    end

    it 'returns the correct ip block' do
      post :create, params: params

      json = body_as_json

      expect(json[:ip]).to eq("#{params[:ip]}/32")
      expect(json[:severity]).to eq(params[:severity])
      expect(json[:comment]).to eq(params[:comment])
    end

    context 'when ip is not provided' do
      let(:params) { { ip: '', severity: 'no_access' } }

      it 'returns http unprocessable entity' do
        post :create, params: params

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

    context 'when severity is not provided' do
      let(:params) { { ip: '173.65.23.1', severity: '' } }

      it 'returns http unprocessable entity' do
        post :create, params: params

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

    context 'when provided ip is already blocked' do
      before do
        IpBlock.create(params)
      end

      it 'returns http unprocessable entity' do
        post :create, params: params

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

    context 'when provided ip address is invalid' do
      let(:params) { { ip: '520.13.54.120', severity: 'no_access' } }

      it 'returns http unprocessable entity' do
        post :create, params: params

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

  describe 'PUT #update' do
    context 'when ip block exists' do
      let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access', comment: 'Spam', expires_in: 48.hours) }
      let(:params) { { id: ip_block.id, severity: 'sign_up_requires_approval', comment: 'Decreasing severity' } }

      it 'returns http success' do
        put :update, params: params

        expect(response).to have_http_status(200)
      end

      it 'returns the correct ip block' do
        put :update, params: params

        json = body_as_json

        expect(json).to match(hash_including({
          ip: "#{ip_block.ip}/#{ip_block.ip.prefix}",
          severity: 'sign_up_requires_approval',
          comment: 'Decreasing severity',
        }))
      end

      it 'updates the severity correctly' do
        expect { put :update, params: params }.to change { ip_block.reload.severity }.from('no_access').to('sign_up_requires_approval')
      end

      it 'updates the comment correctly' do
        expect { put :update, params: params }.to change { ip_block.reload.comment }.from('Spam').to('Decreasing severity')
      end
    end

    context 'when ip block does not exist' do
      it 'returns http not found' do
        put :update, params: { id: 0 }

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

  describe 'DELETE #destroy' do
    context 'when ip block exists' do
      let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access') }
      let(:params) { { id: ip_block.id } }

      it 'returns http success' do
        delete :destroy, params: params

        expect(response).to have_http_status(200)
      end

      it 'returns an empty body' do
        delete :destroy, params: params

        json = body_as_json

        expect(json).to be_empty
      end

      it 'deletes the ip block' do
        delete :destroy, params: params

        expect(IpBlock.find_by(id: ip_block.id)).to be_nil
      end
    end

    context 'when ip block does not exist' do
      it 'returns http not found' do
        delete :destroy, params: { id: 0 }

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

M spec/controllers/api/v1/emails/confirmations_controller_spec.rb => spec/controllers/api/v1/emails/confirmations_controller_spec.rb +68 -0
@@ 63,4 63,72 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do
      end
    end
  end

  describe '#check' do
    let(:scopes) { 'read' }

    context 'with an oauth token' do
      before do
        allow(controller).to receive(:doorkeeper_token) { token }
      end

      context 'when the account is not confirmed' do
        it 'returns http success' do
          get :check
          expect(response).to have_http_status(200)
        end

        it 'returns false' do
          get :check
          expect(body_as_json).to be false
        end
      end

      context 'when the account is confirmed' do
        let(:confirmed_at) { Time.now.utc }

        it 'returns http success' do
          get :check
          expect(response).to have_http_status(200)
        end

        it 'returns true' do
          get :check
          expect(body_as_json).to be true
        end
      end
    end

    context 'with an authentication cookie' do
      before do
        sign_in user, scope: :user
      end

      context 'when the account is not confirmed' do
        it 'returns http success' do
          get :check
          expect(response).to have_http_status(200)
        end

        it 'returns false' do
          get :check
          expect(body_as_json).to be false
        end
      end

      context 'when the account is confirmed' do
        let(:confirmed_at) { Time.now.utc }

        it 'returns http success' do
          get :check
          expect(response).to have_http_status(200)
        end

        it 'returns true' do
          get :check
          expect(body_as_json).to be true
        end
      end
    end
  end
end

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

require 'rails_helper'

describe Api::V1::FeaturedTagsController do
  render_views

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

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

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

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

M spec/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/controllers/concerns/signature_verification_spec.rb => spec/controllers/concerns/signature_verification_spec.rb +1 -1
@@ 129,7 129,7 @@ describe ApplicationController do
      end
    end

    context 'with request with unparseable Date header' do
    context 'with request with unparsable Date header' do
      before do
        get :success


M spec/controllers/statuses_controller_spec.rb => spec/controllers/statuses_controller_spec.rb +128 -13
@@ 719,65 719,180 @@ describe StatusesController do
    end

    context 'when status is public' do
      pending
      before do
        status.update(visibility: :public)
        get :activity, params: { account_username: account.username, id: status.id }
      end

      it 'returns http success' do
        expect(response).to have_http_status(:success)
      end
    end

    context 'when status is private' do
      pending
      before do
        status.update(visibility: :private)
        get :activity, params: { account_username: account.username, id: status.id }
      end

      it 'returns http not_found' do
        expect(response).to have_http_status(404)
      end
    end

    context 'when status is direct' do
      pending
      before do
        status.update(visibility: :direct)
        get :activity, params: { account_username: account.username, id: status.id }
      end

      it 'returns http not_found' do
        expect(response).to have_http_status(404)
      end
    end

    context 'when signed-in' do
      let(:user) { Fabricate(:user) }

      before do
        sign_in(user)
      end

      context 'when status is public' do
        pending
        before do
          status.update(visibility: :public)
          get :activity, params: { account_username: account.username, id: status.id }
        end

        it 'returns http success' do
          expect(response).to have_http_status(:success)
        end
      end

      context 'when status is private' do
        before do
          status.update(visibility: :private)
        end

        context 'when user is authorized to see it' do
          pending
          before do
            user.account.follow!(account)
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http success' do
            expect(response).to have_http_status(200)
          end
        end

        context 'when user is not authorized to see it' do
          pending
          before do
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http not_found' do
            expect(response).to have_http_status(404)
          end
        end
      end

      context 'when status is direct' do
        before do
          status.update(visibility: :direct)
        end

        context 'when user is authorized to see it' do
          pending
          before do
            Fabricate(:mention, account: user.account, status: status)
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http success' do
            expect(response).to have_http_status(200)
          end
        end

        context 'when user is not authorized to see it' do
          pending
          before do
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http not_found' do
            expect(response).to have_http_status(404)
          end
        end
      end
    end

    context 'with signature' do
      let(:remote_account) { Fabricate(:account, domain: 'example.com') }

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

      context 'when status is public' do
        pending
        before do
          status.update(visibility: :public)
          get :activity, params: { account_username: account.username, id: status.id }
        end

        it 'returns http success' do
          expect(response).to have_http_status(:success)
        end
      end

      context 'when status is private' do
        before do
          status.update(visibility: :private)
        end

        context 'when user is authorized to see it' do
          pending
          before do
            remote_account.follow!(account)
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http success' do
            expect(response).to have_http_status(200)
          end
        end

        context 'when user is not authorized to see it' do
          pending
          before do
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http not_found' do
            expect(response).to have_http_status(404)
          end
        end
      end

      context 'when status is direct' do
        before do
          status.update(visibility: :direct)
        end

        context 'when user is authorized to see it' do
          pending
          before do
            Fabricate(:mention, account: remote_account, status: status)
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http success' do
            expect(response).to have_http_status(200)
          end
        end

        context 'when user is not authorized to see it' do
          pending
          before do
            get :activity, params: { account_username: account.username, id: status.id }
          end

          it 'returns http not_found' do
            expect(response).to have_http_status(404)
          end
        end
      end
    end

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

Fabricator(:canonical_email_block) do
  email 'test@example.com'
  email { sequence(:email) { |i| "#{i}#{Faker::Internet.email}" } }
  reference_account { Fabricate(:account) }
end

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

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/features/captcha_spec.rb => spec/features/captcha_spec.rb +35 -0
@@ 0,0 1,35 @@
# frozen_string_literal: true

require 'rails_helper'

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

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

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

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

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

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

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

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

M spec/lib/activitypub/activity/flag_spec.rb => spec/lib/activitypub/activity/flag_spec.rb +31 -0
@@ 39,6 39,37 @@ RSpec.describe ActivityPub::Activity::Flag do
      end
    end

    context 'when the report comment is excessively long' do
      subject do
        described_class.new({
          '@context': 'https://www.w3.org/ns/activitystreams',
          id: flag_id,
          type: 'Flag',
          content: long_comment,
          actor: ActivityPub::TagManager.instance.uri_for(sender),
          object: [
            ActivityPub::TagManager.instance.uri_for(flagged),
            ActivityPub::TagManager.instance.uri_for(status),
          ],
        }.with_indifferent_access, sender)
      end

      let(:long_comment) { Faker::Lorem.characters(number: 6000) }

      before do
        subject.perform
      end

      it 'creates a report but with a truncated comment' do
        report = Report.find_by(account: sender, target_account: flagged)

        expect(report).to_not be_nil
        expect(report.comment.length).to eq 5000
        expect(report.comment).to eq long_comment[0...5000]
        expect(report.status_ids).to eq [status.id]
      end
    end

    context 'when the reported status is private and should not be visible to the remote server' do
      let(:status) { Fabricate(:status, account: flagged, uri: 'foobar', visibility: :private) }


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

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

require 'rails_helper'
require 'mastodon/migration_warning'

describe Mastodon::MigrationWarning do
  describe 'migration_duration_warning' do
    before do
      allow(migration).to receive(:valid_environment?).and_return(true)
      allow(migration).to receive(:sleep).with(1)
    end

    let(:migration) { Class.new(ActiveRecord::Migration[6.1]).extend(described_class) }

    context 'with the default message' do
      it 'warns about long migrations' do
        expectation = expect { migration.migration_duration_warning }

        expectation.to output(/interrupt this migration/).to_stdout
        expectation.to output(/Continuing in 5/).to_stdout
      end
    end

    context 'with an additional message' do
      it 'warns about long migrations' do
        expectation = expect { migration.migration_duration_warning('Get ready for it') }

        expectation.to output(/interrupt this migration/).to_stdout
        expectation.to output(/Get ready for it/).to_stdout
        expectation.to output(/Continuing in 5/).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

M spec/models/account_migration_spec.rb => spec/models/account_migration_spec.rb +1 -1
@@ 25,7 25,7 @@ RSpec.describe AccountMigration do
      end
    end

    context 'with unresolveable account' do
    context 'with unresolvable account' do
      let(:target_acct) { 'target@remote' }

      before do

M spec/models/account_spec.rb => spec/models/account_spec.rb +1 -1
@@ 698,7 698,7 @@ RSpec.describe Account do
      expect(subject.match('Check this out https://medium.com/@alice/some-article#.abcdef123')).to be_nil
    end

    xit 'does not match URL querystring' do
    xit 'does not match URL query string' do
      expect(subject.match('https://example.com/?x=@alice')).to be_nil
    end
  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/models/report_spec.rb => spec/models/report_spec.rb +9 -2
@@ 121,10 121,17 @@ describe Report do
  end

  describe 'validations' do
    it 'is invalid if comment is longer than 1000 characters' do
    let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }

    it 'is invalid if comment is longer than 1000 characters only if reporter is local' do
      report = Fabricate.build(:report, comment: Faker::Lorem.characters(number: 1001))
      report.valid?
      expect(report.valid?).to be false
      expect(report).to model_have_error_on_field(:comment)
    end

    it 'is valid if comment is longer than 1000 characters and reporter is not local' do
      report = Fabricate.build(:report, account: remote_account, comment: Faker::Lorem.characters(number: 1001))
      expect(report.valid?).to be true
    end
  end
end

M spec/models/user_settings/setting_spec.rb => spec/models/user_settings/setting_spec.rb +1 -1
@@ 90,7 90,7 @@ RSpec.describe UserSettings::Setting do

  describe '#key' do
    context 'when there is no namespace' do
      it 'returnsn a symbol' do
      it 'returns a symbol' do
        expect(subject.key).to eq :foo
      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/policies/status_policy_spec.rb => spec/policies/status_policy_spec.rb +102 -90
@@ 11,139 11,151 @@ RSpec.describe StatusPolicy, type: :model do
  let(:bob) { Fabricate(:account, username: 'bob') }
  let(:status) { Fabricate(:status, account: alice) }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    it 'denies access when local-only and the viewer is not logged in' do
      allow(status).to receive(:local_only?).and_return(true)
      it 'denies access when local-only and the viewer is not logged in' do
        allow(status).to receive(:local_only?).and_return(true)

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

    it 'denies access when local-only and the viewer is from another domain' do
      viewer = Fabricate(:account, domain: 'remote-domain')
      allow(status).to receive(:local_only?).and_return(true)
      expect(subject).to_not permit(viewer, status)
      it 'denies access when local-only and the viewer is from another domain' do
        viewer = Fabricate(:account, domain: 'remote-domain')
        allow(status).to receive(:local_only?).and_return(true)
        expect(subject).to_not permit(viewer, status)
      end
    end
  end

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

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

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

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

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

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

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

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

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

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

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

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

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

    context 'when options are not set' do

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

require 'rails_helper'

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

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

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

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

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

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

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

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

      expect(response).to have_http_status(200)
    end

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

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

        body = body_as_json

        expect(body).to be_empty
      end
    end

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

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

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

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

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

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

      expect(response).to have_http_status(200)
    end

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

      body = body_as_json

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

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

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

      expect(featured_tag).to be_present
    end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      expect(response).to have_http_status(200)
    end

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

      body = body_as_json

      expect(body).to be_empty
    end

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

      featured_tag = FeaturedTag.find_by(id: id)

      expect(featured_tag).to be_nil
    end

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

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

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

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

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

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

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

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

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

M spec/services/activitypub/process_account_service_spec.rb => spec/services/activitypub/process_account_service_spec.rb +3 -5
@@ 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'],


@@ 183,7 181,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
          '@context': ['https://www.w3.org/ns/activitystreams'],
          id: "https://foo.test/users/#{i}/featured",
          type: 'OrderedCollection',
          totelItems: 1,
          totalItems: 1,
          orderedItems: [status_json],
        }.with_indifferent_access
        webfinger = {

M spec/services/backup_service_spec.rb => spec/services/backup_service_spec.rb +21 -0
@@ 21,6 21,27 @@ RSpec.describe BackupService, type: :service do
    end
  end

  context 'when the user has an avatar and header' do
    before do
      user.account.update!(avatar: attachment_fixture('avatar.gif'))
      user.account.update!(header: attachment_fixture('emojo.png'))
    end

    it 'stores them as expected' do
      service_call

      json = Oj.load(read_zip_file(backup, 'actor.json'))
      avatar_path = json.dig('icon', 'url')
      header_path = json.dig('image', 'url')

      expect(avatar_path).to_not be_nil
      expect(header_path).to_not be_nil

      expect(read_zip_file(backup, avatar_path)).to be_present
      expect(read_zip_file(backup, header_path)).to be_present
    end
  end

  it 'marks the backup as processed' do
    expect { service_call }.to change(backup, :processed).from(false).to(true)
  end

M spec/services/report_service_spec.rb => spec/services/report_service_spec.rb +9 -3
@@ 6,6 6,14 @@ RSpec.describe ReportService, type: :service do
  subject { described_class.new }

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

  context 'with a local account' do
    it 'has a uri' do
      report = subject.call(source_account, target_account)
      expect(report.uri).to_not be_nil
    end
  end

  context 'with a remote account' do
    let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }


@@ 35,7 43,6 @@ RSpec.describe ReportService, type: :service do
      -> { described_class.new.call(source_account, target_account, status_ids: [status.id]) }
    end

    let(:target_account) { Fabricate(:account) }
    let(:status) { Fabricate(:status, account: target_account, visibility: :direct) }

    context 'when it is addressed to the reporter' do


@@ 91,8 98,7 @@ RSpec.describe ReportService, type: :service do
      -> {  described_class.new.call(source_account, target_account) }
    end

    let!(:target_account) { Fabricate(:account) }
    let!(:other_report)   { Fabricate(:report, target_account: target_account) }
    let!(:other_report) { Fabricate(:report, target_account: target_account) }

    before do
      ActionMailer::Base.deliveries.clear

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 tsconfig.json => tsconfig.json +2 -0
@@ 12,6 12,8 @@
    "baseUrl": "./",
    "paths": {
      "locales": ["app/javascript/locales"],
      "styles/*": ["app/javascript/styles/*"],
      "packs/public-path": ["app/javascript/packs/public-path"],
      "flavours/glitch": ["app/javascript/flavours/glitch"],
      "flavours/glitch/*": ["app/javascript/flavours/glitch/*"],
      "mastodon": ["app/javascript/mastodon"],

M yarn.lock => yarn.lock +371 -247
@@ 1055,14 1055,6 @@
  resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
  integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==

"@babel/runtime-corejs3@^7.10.2":
  version "7.10.3"
  resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a"
  integrity sha512-HA7RPj5xvJxQl429r5Cxr2trJwOfPjKiqhCXcdQPSqO2G0RHPZpXu4fkYmBaTKCp2c/jRaMK9GB/lN+7zvvFPw==
  dependencies:
    core-js-pure "^3.0.0"
    regenerator-runtime "^0.13.4"

"@babel/runtime@7.0.0":
  version "7.0.0"
  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0.tgz#adeb78fedfc855aa05bc041640f3f6f98e85424c"


@@ 1070,7 1062,7 @@
  dependencies:
    regenerator-runtime "^0.12.0"

"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.8", "@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.8", "@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
  version "7.21.5"
  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
  integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==


@@ 1224,10 1216,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 1237,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 1252,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 1670,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"


@@ 1810,18 1814,18 @@
    magic-string "^0.25.0"
    string.prototype.matchall "^4.0.6"

"@testing-library/dom@^8.0.0":
  version "8.1.0"
  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.1.0.tgz#f8358b1883844ea569ba76b7e94582168df5370d"
  integrity sha512-kmW9alndr19qd6DABzQ978zKQ+J65gU2Rzkl8hriIetPnwpesRaK4//jEQyYh8fEALmGhomD/LBQqt+o+DL95Q==
"@testing-library/dom@^9.0.0":
  version "9.2.0"
  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.2.0.tgz#0e1f45e956f2a16f471559c06edd8827c4832f04"
  integrity sha512-xTEnpUKiV/bMyEsE5bT4oYA0x0Z/colMtxzUY8bKyPXBNLn/e0V4ZjBZkEhms0xE4pv9QsPfSRu9AWS4y5wGvA==
  dependencies:
    "@babel/code-frame" "^7.10.4"
    "@babel/runtime" "^7.12.5"
    "@types/aria-query" "^4.2.0"
    aria-query "^4.2.2"
    "@types/aria-query" "^5.0.1"
    aria-query "^5.0.0"
    chalk "^4.1.0"
    dom-accessibility-api "^0.5.6"
    lz-string "^1.4.4"
    dom-accessibility-api "^0.5.9"
    lz-string "^1.5.0"
    pretty-format "^27.0.2"

"@testing-library/jest-dom@^5.16.5":


@@ 1839,14 1843,14 @@
    lodash "^4.17.15"
    redent "^3.0.0"

"@testing-library/react@^12.1.5":
  version "12.1.5"
  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
  integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==
"@testing-library/react@^14.0.0":
  version "14.0.0"
  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.0.0.tgz#59030392a6792450b9ab8e67aea5f3cc18d6347c"
  integrity sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==
  dependencies:
    "@babel/runtime" "^7.12.5"
    "@testing-library/dom" "^8.0.0"
    "@types/react-dom" "<18.0.0"
    "@testing-library/dom" "^9.0.0"
    "@types/react-dom" "^18.0.0"

"@tootallnate/once@2":
  version "2.0.0"


@@ 1858,10 1862,10 @@
  resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
  integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==

"@types/aria-query@^4.2.0":
  version "4.2.0"
  resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0"
  integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==
"@types/aria-query@^5.0.1":
  version "5.0.1"
  resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc"
  integrity sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==

"@types/babel__core@^7.1.12", "@types/babel__core@^7.1.14", "@types/babel__core@^7.1.3":
  version "7.1.18"


@@ 2172,29 2176,17 @@
  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==

"@types/raf@^3.4.0":
  version "3.4.0"
  resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2"
  integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw==

"@types/range-parser@*":
  version "1.2.4"
  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==

"@types/react-dom@<18.0.0":
  version "17.0.15"
  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.15.tgz#f2c8efde11521a4b7991e076cb9c70ba3bb0d156"
  integrity sha512-Tr9VU9DvNoHDWlmecmcsE5ZZiUkYx+nKBzum4Oxe1K0yJVyBlfbq7H3eXjxXqJczBKqPGq3EgfTru4MgKb9+Yw==
  dependencies:
    "@types/react" "^17"

"@types/react-dom@^16.9.18":
  version "16.9.18"
  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.18.tgz#1fda8b84370b1339d639a797a84c16d5a195b419"
  integrity sha512-lmNARUX3+rNF/nmoAFqasG0jAA7q6MeGZK/fdeLwY3kAA4NPgHHrG5bNQe2B5xmD4B+x6Z6h0rEJQ7MEEgQxsw==
"@types/react-dom@^18.0.0", "@types/react-dom@^18.2.4":
  version "18.2.4"
  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.4.tgz#13f25bfbf4e404d26f62ac6e406591451acba9e0"
  integrity sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==
  dependencies:
    "@types/react" "^16"
    "@types/react" "*"

"@types/react-helmet@^6.1.6":
  version "6.1.6"


@@ 2316,28 2308,10 @@
  dependencies:
    "@types/react" "*"

"@types/react@*", "@types/react@^17":
  version "17.0.44"
  resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.44.tgz#c3714bd34dd551ab20b8015d9d0dbec812a51ec7"
  integrity sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g==
  dependencies:
    "@types/prop-types" "*"
    "@types/scheduler" "*"
    csstype "^3.0.2"

"@types/react@>=16.9.11":
  version "18.0.26"
  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.26.tgz#8ad59fc01fef8eaf5c74f4ea392621749f0b7917"
  integrity sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug==
  dependencies:
    "@types/prop-types" "*"
    "@types/scheduler" "*"
    csstype "^3.0.2"

"@types/react@^16", "@types/react@^16.14.38":
  version "16.14.38"
  resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.38.tgz#b814d157ca8906603593d5106f6d733af9b79df4"
  integrity sha512-PbEjuhwkdH6IB5Sak6BFAqpVMHY/wJxa0EG3bKkr0vWA2hSDIq3iEMhHyqjXrDFMqRzkiQkdyNXOnoELrh/9aQ==
"@types/react@*", "@types/react@>=16.9.11", "@types/react@^18.0.26":
  version "18.2.6"
  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.6.tgz#5cd53ee0d30ffc193b159d3516c8c8ad2f19d571"
  integrity sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==
  dependencies:
    "@types/prop-types" "*"
    "@types/scheduler" "*"


@@ 2475,15 2449,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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.7.tgz#e470af414f05ecfdc05a23e9ce6ec8f91db56fe2"
  integrity sha512-BL+jYxUFIbuYwy+4fF86k5vdT9lT0CNJ6HtwrIvGh0PhH8s0yy5rjaKH2fDCrz5ITHy07WCzVGNvAmjJh4IJFA==
  dependencies:
    "@eslint-community/regexpp" "^4.4.0"
    "@typescript-eslint/scope-manager" "5.59.5"
    "@typescript-eslint/type-utils" "5.59.5"
    "@typescript-eslint/utils" "5.59.5"
    "@typescript-eslint/scope-manager" "5.59.7"
    "@typescript-eslint/type-utils" "5.59.7"
    "@typescript-eslint/utils" "5.59.7"
    debug "^4.3.4"
    grapheme-splitter "^1.0.4"
    ignore "^5.2.0"


@@ 2491,31 2465,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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.7.tgz#02682554d7c1028b89aa44a48bf598db33048caa"
  integrity sha512-VhpsIEuq/8i5SF+mPg9jSdIwgMBBp0z9XqjiEay+81PYLJuroN+ET1hM5IhkiYMJd9MkTz8iJLt7aaGAgzWUbQ==
  dependencies:
    "@typescript-eslint/scope-manager" "5.59.5"
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/typescript-estree" "5.59.5"
    "@typescript-eslint/scope-manager" "5.59.7"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/typescript-estree" "5.59.7"
    debug "^4.3.4"

"@typescript-eslint/scope-manager@5.59.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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.7.tgz#0243f41f9066f3339d2f06d7f72d6c16a16769e2"
  integrity sha512-FL6hkYWK9zBGdxT2wWEd2W8ocXMu3K94i3gvMrjXpx+koFYdYV7KprKfirpgY34vTGzEPPuKoERpP8kD5h7vZQ==
  dependencies:
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/visitor-keys" "5.59.5"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/visitor-keys" "5.59.7"

"@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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.7.tgz#89c97291371b59eb18a68039857c829776f1426d"
  integrity sha512-ozuz/GILuYG7osdY5O5yg0QxXUAEoI4Go3Do5xeu+ERH9PorHBPSdvD3Tjp2NN2bNLh1NJQSsQu2TPu/Ly+HaQ==
  dependencies:
    "@typescript-eslint/typescript-estree" "5.59.5"
    "@typescript-eslint/utils" "5.59.5"
    "@typescript-eslint/typescript-estree" "5.59.7"
    "@typescript-eslint/utils" "5.59.7"
    debug "^4.3.4"
    tsutils "^3.21.0"



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

"@typescript-eslint/types@5.59.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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.7.tgz#6f4857203fceee91d0034ccc30512d2939000742"
  integrity sha512-UnVS2MRRg6p7xOSATscWkKjlf/NDKuqo5TdbWck6rIRZbmKpVNTLALzNvcjIfHBE7736kZOFc/4Z3VcZwuOM/A==

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


@@ 2542,30 2516,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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.7.tgz#b887acbd4b58e654829c94860dbff4ac55c5cff8"
  integrity sha512-4A1NtZ1I3wMN2UGDkU9HMBL+TIQfbrh4uS0WDMMpf3xMRursDbqEf1ahh6vAAe3mObt8k3ZATnezwG4pdtWuUQ==
  dependencies:
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/visitor-keys" "5.59.5"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/visitor-keys" "5.59.7"
    debug "^4.3.4"
    globby "^11.1.0"
    is-glob "^4.0.3"
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/utils@5.59.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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.7.tgz#7adf068b136deae54abd9a66ba5a8780d2d0f898"
  integrity sha512-yCX9WpdQKaLufz5luG4aJbOpdXf/fjwGMcLFXZVPUz3QqLirG5QcwwnIHNf8cjLjxK4qtzTO8udUtMQSAToQnQ==
  dependencies:
    "@eslint-community/eslint-utils" "^4.2.0"
    "@types/json-schema" "^7.0.9"
    "@types/semver" "^7.3.12"
    "@typescript-eslint/scope-manager" "5.59.5"
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/typescript-estree" "5.59.5"
    "@typescript-eslint/scope-manager" "5.59.7"
    "@typescript-eslint/types" "5.59.7"
    "@typescript-eslint/typescript-estree" "5.59.7"
    eslint-scope "^5.1.1"
    semver "^7.3.7"



@@ 2577,12 2551,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.7":
  version "5.59.7"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.7.tgz#09c36eaf268086b4fbb5eb9dc5199391b6485fc5"
  integrity sha512-tyN+X2jvMslUszIiYbF0ZleP+RqQsFVpGrKI6e0Eet1w8WmhsAtmzaqm8oM8WJQ1ysLwhnsK/4hYHJjOgJVfQQ==
  dependencies:
    "@typescript-eslint/types" "5.59.5"
    "@typescript-eslint/types" "5.59.7"
    eslint-visitor-keys "^3.3.0"

"@webassemblyjs/ast@1.9.0":


@@ 2788,7 2762,7 @@ acorn@^6.4.1:
  resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474"
  integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==

acorn@^8.0.4, acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.0, acorn@^8.8.1, acorn@^8.8.2:
acorn@^8.0.4, acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.0, acorn@^8.8.1:
  version "8.8.2"
  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
  integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==


@@ 2968,14 2942,6 @@ argparse@^2.0.1:
  resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
  integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==

aria-query@^4.2.2:
  version "4.2.2"
  resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
  integrity sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==
  dependencies:
    "@babel/runtime" "^7.10.2"
    "@babel/runtime-corejs3" "^7.10.2"

aria-query@^5.0.0, aria-query@^5.1.3:
  version "5.1.3"
  resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e"


@@ 3379,6 3345,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 3432,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 3614,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"


@@ 4130,11 4115,6 @@ core-js-compat@^3.25.1:
  dependencies:
    browserslist "^4.21.4"

core-js-pure@^3.0.0:
  version "3.6.5"
  resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813"
  integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA==

core-js@^2.5.0:
  version "2.6.12"
  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"


@@ 4536,6 4516,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 4542,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"


@@ 4712,6 4715,11 @@ dom-accessibility-api@^0.5.6:
  resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz#3f5d43b52c7a3bd68b5fb63fa47b4e4c1fdf65a9"
  integrity sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw==

dom-accessibility-api@^0.5.9:
  version "0.5.16"
  resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453"
  integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==

dom-helpers@^3.4.0:
  version "3.4.0"
  resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"


@@ 4882,6 4890,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 5036,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 5095,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 5193,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 5217,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 5244,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 5355,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 5487,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 5859,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"


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

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

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


@@ 5939,6 5974,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 6013,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 6673,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 6739,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 6929,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"


@@ 7416,19 7491,16 @@ jsdom@^20.0.0:
    ws "^8.11.0"
    xml-name-validator "^4.0.0"

jsdom@^21.1.2:
  version "21.1.2"
  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.2.tgz#6433f751b8718248d646af1cdf6662dc8a1ca7f9"
  integrity sha512-sCpFmK2jv+1sjff4u7fzft+pUh2KSUbUrEHYHyfSIbGTIcmnjyp83qg6qLwdJ/I3LpTXx33ACxeRL7Lsyc6lGQ==
jsdom@^22.0.0:
  version "22.0.0"
  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-22.0.0.tgz#3295c6992c70089c4b8f5cf060489fddf7ee9816"
  integrity sha512-p5ZTEb5h+O+iU02t0GfEjAnkdYPrQSkfuTSMkMYyIoMvUNEHsbG0bHHbfXIcfTqD2UfvjQX7mmgiFsyRwGscVw==
  dependencies:
    abab "^2.0.6"
    acorn "^8.8.2"
    acorn-globals "^7.0.0"
    cssstyle "^3.0.0"
    data-urls "^4.0.0"
    decimal.js "^10.4.3"
    domexception "^4.0.0"
    escodegen "^2.0.0"
    form-data "^4.0.0"
    html-encoding-sniffer "^3.0.0"
    http-proxy-agent "^5.0.0"


@@ 7805,10 7877,10 @@ lru-cache@^9.0.0:
  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-9.0.1.tgz#ac061ed291f8b9adaca2b085534bb1d3b61bef83"
  integrity sha512-C8QsKIN1UIXeOs3iWmiZ1lQY+EnKDojWd37fXy1aSbJvH4iSma1uy2OWuoB3m4SYRli5+CUjDv3Dij5DVoetmg==

lz-string@^1.4.4:
  version "1.4.4"
  resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
  integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=
lz-string@^1.5.0:
  version "1.5.0"
  resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
  integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==

magic-string@^0.25.0, magic-string@^0.25.7:
  version "0.25.9"


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

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



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

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

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


@@ 8145,10 8222,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 8585,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 8873,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"


@@ 9394,7 9476,7 @@ quick-lru@^4.0.1:
  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
  integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==

raf@^3.1.0, raf@^3.4.1:
raf@^3.1.0:
  version "3.4.1"
  resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
  integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==


@@ 9431,15 9513,13 @@ raw-body@2.5.1:
    iconv-lite "0.4.24"
    unpipe "1.0.0"

react-dom@^16.14.0:
  version "16.14.0"
  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
  integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
react-dom@^18.2.0:
  version "18.2.0"
  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
  integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
  dependencies:
    loose-envify "^1.1.0"
    object-assign "^4.1.1"
    prop-types "^15.6.2"
    scheduler "^0.19.1"
    scheduler "^0.23.0"

react-event-listener@^0.6.0:
  version "0.6.6"


@@ 9509,7 9589,12 @@ react-intl@^2.9.0:
    intl-relativeformat "^2.1.0"
    invariant "^2.1.1"

react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.6:
"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.2.0:
  version "18.2.0"
  resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
  integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==

react-is@^16.13.1, react-is@^16.7.0:
  version "16.13.1"
  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
  integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==


@@ 9627,6 9712,14 @@ react-select@*, react-select@^5.7.3:
    react-transition-group "^4.3.0"
    use-isomorphic-layout-effect "^1.1.2"

react-shallow-renderer@^16.15.0:
  version "16.15.0"
  resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
  integrity sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==
  dependencies:
    object-assign "^4.1.1"
    react-is "^16.12.0 || ^17.0.0 || ^18.0.0"

react-side-effect@^2.1.0:
  version "2.1.2"
  resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a"


@@ 9670,15 9763,14 @@ react-swipeable-views@^0.14.0:
    react-swipeable-views-utils "^0.14.0"
    warning "^4.0.1"

react-test-renderer@^16.14.0:
  version "16.14.0"
  resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae"
  integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg==
react-test-renderer@^18.2.0:
  version "18.2.0"
  resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-18.2.0.tgz#1dd912bd908ff26da5b9fca4fd1c489b9523d37e"
  integrity sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==
  dependencies:
    object-assign "^4.1.1"
    prop-types "^15.6.2"
    react-is "^16.8.6"
    scheduler "^0.19.1"
    react-is "^18.2.0"
    react-shallow-renderer "^16.15.0"
    scheduler "^0.23.0"

react-textarea-autosize@*, react-textarea-autosize@^8.4.1:
  version "8.4.1"


@@ 9706,14 9798,12 @@ react-transition-group@^4.3.0:
    loose-envify "^1.4.0"
    prop-types "^15.6.2"

react@^16.14.0:
  version "16.14.0"
  resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
  integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
react@^18.2.0:
  version "18.2.0"
  resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
  integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
  dependencies:
    loose-envify "^1.1.0"
    object-assign "^4.1.1"
    prop-types "^15.6.2"

read-pkg-up@^7.0.1:
  version "7.0.1"


@@ 9843,7 9933,7 @@ regenerator-runtime@^0.12.0:
  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
  integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==

regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4:
regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.3:
  version "0.13.11"
  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
  integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==


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

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

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


@@ 10114,6 10204,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"


@@ 10186,13 10283,12 @@ saxes@^6.0.0:
  dependencies:
    xmlchars "^2.2.0"

scheduler@^0.19.1:
  version "0.19.1"
  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196"
  integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==
scheduler@^0.23.0:
  version "0.23.0"
  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
  integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
  dependencies:
    loose-envify "^1.1.0"
    object-assign "^4.1.1"

schema-utils@^1.0.0:
  version "1.0.0"


@@ 10251,10 10347,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 10522,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"


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

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


@@ 11070,6 11171,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 11195,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 11322,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 11435,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 11653,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"


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

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