~cytrogen/masto-fe

ee02b10e06163f92cd0d092d8445ec75de7d3a87 — Claire 2 years ago 786e586 + 5c7df20
Merge pull request #2392 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes into glitch-soc
147 files changed, 3207 insertions(+), 930 deletions(-)

M .devcontainer/Dockerfile
A .devcontainer/codespaces/devcontainer.json
M .devcontainer/devcontainer.json
M .devcontainer/docker-compose.yml
M .github/workflows/build-container-image.yml
M .github/workflows/build-nightly.yml
M .github/workflows/build-push-pr.yml
M .github/workflows/build-releases.yml
M CHANGELOG.md
M Dockerfile
M Gemfile.lock
M Procfile.dev
M app/chewy/accounts_index.rb
A app/chewy/public_statuses_index.rb
M app/chewy/statuses_index.rb
A app/controllers/admin/software_updates_controller.rb
M app/controllers/api/v1/accounts/credentials_controller.rb
M app/controllers/api/v1/statuses/translations_controller.rb
M app/controllers/api/v1/timelines/tag_controller.rb
M app/controllers/application_controller.rb
M app/controllers/concerns/signature_verification.rb
M app/controllers/settings/privacy_controller.rb
A app/helpers/authorized_fetch_helper.rb
D app/javascript/core/public.js
M app/javascript/core/settings.js
M app/javascript/core/theme.yml
M app/javascript/flavours/glitch/actions/interactions.js
M app/javascript/flavours/glitch/components/status.jsx
M app/javascript/flavours/glitch/features/compose/components/search.jsx
M app/javascript/flavours/glitch/features/favourites/index.jsx
A app/javascript/flavours/glitch/features/home_timeline/components/critical_update_banner.tsx
M app/javascript/flavours/glitch/features/home_timeline/index.jsx
M app/javascript/flavours/glitch/features/reblogs/index.jsx
M app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx
M app/javascript/flavours/glitch/initial_state.js
M app/javascript/flavours/glitch/reducers/user_lists.js
M app/javascript/flavours/glitch/styles/accounts.scss
M app/javascript/flavours/glitch/styles/admin.scss
M app/javascript/flavours/glitch/styles/components/columns.scss
M app/javascript/flavours/glitch/styles/components/search.scss
M app/javascript/flavours/glitch/styles/components/single_column.scss
M app/javascript/flavours/glitch/styles/forms.scss
M app/javascript/flavours/glitch/styles/tables.scss
M app/javascript/mastodon/actions/compose.js
M app/javascript/mastodon/actions/interactions.js
M app/javascript/mastodon/components/hashtag_bar.tsx
M app/javascript/mastodon/components/status.jsx
M app/javascript/mastodon/features/compose/components/search.jsx
M app/javascript/mastodon/features/favourites/index.jsx
A app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx
M app/javascript/mastodon/features/home_timeline/index.jsx
M app/javascript/mastodon/features/reblogs/index.jsx
M app/javascript/mastodon/features/ui/components/navigation_panel.jsx
M app/javascript/mastodon/initial_state.js
M app/javascript/mastodon/locales/en.json
M app/javascript/mastodon/locales/fr.json
M app/javascript/mastodon/reducers/user_lists.js
M app/javascript/mastodon/test_setup.js
M app/javascript/packs/public.jsx
M app/javascript/styles/mastodon/accounts.scss
M app/javascript/styles/mastodon/admin.scss
M app/javascript/styles/mastodon/components.scss
M app/javascript/styles/mastodon/forms.scss
M app/javascript/styles/mastodon/tables.scss
M app/lib/admin/metrics/dimension/software_versions_dimension.rb
M app/lib/admin/system_check.rb
M app/lib/admin/system_check/elasticsearch_check.rb
A app/lib/admin/system_check/software_version_check.rb
M app/lib/importer/accounts_index_importer.rb
M app/lib/importer/base_importer.rb
M app/lib/importer/instances_index_importer.rb
A app/lib/importer/public_statuses_index_importer.rb
M app/lib/importer/statuses_index_importer.rb
M app/lib/importer/tags_index_importer.rb
M app/lib/plain_text_formatter.rb
M app/lib/search_query_parser.rb
M app/lib/search_query_transformer.rb
M app/lib/vacuum/statuses_vacuum.rb
M app/mailers/admin_mailer.rb
M app/models/account.rb
A app/models/concerns/account_statuses_search.rb
A app/models/concerns/status_search_concern.rb
M app/models/form/admin_settings.rb
M app/models/media_attachment.rb
M app/models/poll.rb
A app/models/software_update.rb
M app/models/status.rb
M app/models/user_settings.rb
A app/policies/software_update_policy.rb
M app/presenters/initial_state_presenter.rb
M app/serializers/activitypub/actor_serializer.rb
M app/serializers/initial_state_serializer.rb
M app/serializers/webfinger_serializer.rb
M app/services/batched_remove_status_service.rb
M app/services/concerns/payloadable.rb
M app/services/search_service.rb
A app/services/software_update_check_service.rb
A app/services/statuses_search_service.rb
M app/views/admin/settings/discovery/show.html.haml
A app/views/admin/software_updates/index.html.haml
A app/views/admin_mailer/new_critical_software_updates.text.erb
A app/views/admin_mailer/new_software_updates.text.erb
M app/views/settings/preferences/notifications/show.html.haml
M app/views/settings/privacy/show.html.haml
A app/workers/add_to_public_statuses_index_worker.rb
A app/workers/remove_from_public_statuses_index_worker.rb
M app/workers/scheduler/indexing_scheduler.rb
A app/workers/scheduler/software_update_check_scheduler.rb
M config/environments/development.rb
M config/i18n-tasks.yml
M config/initializers/simple_form.rb
M config/locales/en.yml
M config/locales/simple_form.en.yml
M config/navigation.rb
M config/routes/admin.rb
M config/sidekiq.yml
M config/webpacker.yml
A db/migrate/20230822081029_create_software_updates.rb
M db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb
M db/schema.rb
M dist/nginx.conf
M lib/mastodon/cli/search.rb
M lib/mastodon/version.rb
M lib/paperclip/transcoder.rb
M lib/tasks/mastodon.rake
M package.json
A spec/chewy/public_statuses_index_spec.rb
M spec/controllers/api/v1/timelines/tag_controller_spec.rb
M spec/controllers/well_known/webfinger_controller_spec.rb
A spec/fabricators/software_update_fabricator.rb
A spec/features/admin/software_updates_spec.rb
M spec/lib/admin/system_check/elasticsearch_check_spec.rb
A spec/lib/admin/system_check/software_version_check_spec.rb
A spec/lib/importer/public_statuses_index_importer_spec.rb
A spec/lib/search_query_parser_spec.rb
M spec/lib/search_query_transformer_spec.rb
M spec/mailers/admin_mailer_spec.rb
A spec/models/concerns/account_statuses_search_spec.rb
A spec/models/software_update_spec.rb
A spec/policies/software_update_policy_spec.rb
M spec/services/search_service_spec.rb
A spec/services/software_update_check_service_spec.rb
A spec/workers/add_to_public_statuses_index_worker_spec.rb
A spec/workers/remove_from_public_statuses_index_worker_spec.rb
A spec/workers/scheduler/software_update_check_scheduler_spec.rb
M streaming/index.js
M yarn.lock
M .devcontainer/Dockerfile => .devcontainer/Dockerfile +0 -4
@@ 4,10 4,6 @@ FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bullseye
# Install Rails
# RUN gem install rails webdrivers

# Default value to allow debug server to serve content over GitHub Codespace's port forwarding service
# The value is a comma-separated list of allowed domains
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev,.preview.app.github.dev,.app.github.dev"

ARG NODE_VERSION="16"
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"


A .devcontainer/codespaces/devcontainer.json => .devcontainer/codespaces/devcontainer.json +49 -0
@@ 0,0 1,49 @@
{
  "name": "Mastodon on GitHub Codespaces",
  "dockerComposeFile": "../docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

  "features": {
    "ghcr.io/devcontainers/features/sshd:1": {}
  },

  "runServices": ["app", "db", "redis"],

  "forwardPorts": [3000, 4000],

  "portsAttributes": {
    "3000": {
      "label": "web",
      "onAutoForward": "notify"
    },
    "4000": {
      "label": "stream",
      "onAutoForward": "silent"
    }
  },

  "otherPortsAttributes": {
    "onAutoForward": "silent"
  },

  "remoteEnv": {
    "LOCAL_DOMAIN": "${localEnv:CODESPACE_NAME}-3000.app.github.dev",
    "LOCAL_HTTPS": "true",
    "STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev",
    "DISABLE_FORGERY_REQUEST_PROTECTION": "true",
    "ES_ENABLED": "",
    "LIBRE_TRANSLATE_ENDPOINT": ""
  },

  "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
  "postCreateCommand": ".devcontainer/post-create.sh",
  "waitFor": "postCreateCommand",

  "customizations": {
    "vscode": {
      "settings": {},
      "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"]
    }
  }
}

M .devcontainer/devcontainer.json => .devcontainer/devcontainer.json +16 -6
@@ 1,5 1,5 @@
{
  "name": "Mastodon",
  "name": "Mastodon on local machine",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",


@@ 8,13 8,23 @@
    "ghcr.io/devcontainers/features/sshd:1": {}
  },

  "runServices": ["app", "db", "redis"],

  "forwardPorts": [3000, 4000],

  "containerEnv": {
    "ES_ENABLED": "",
    "LIBRE_TRANSLATE_ENDPOINT": ""
  "portsAttributes": {
    "3000": {
      "label": "web",
      "onAutoForward": "notify",
      "requireLocalPort": true
    },
    "4000": {
      "label": "stream",
      "onAutoForward": "silent",
      "requireLocalPort": true
    }
  },

  "otherPortsAttributes": {
    "onAutoForward": "silent"
  },

  "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",

M .devcontainer/docker-compose.yml => .devcontainer/docker-compose.yml +1 -0
@@ 25,6 25,7 @@ services:
    command: sleep infinity
    ports:
      - '127.0.0.1:3000:3000'
      - '127.0.0.1:3035:3035'
      - '127.0.0.1:4000:4000'
    networks:
      - external_network

M .github/workflows/build-container-image.yml => .github/workflows/build-container-image.yml +6 -4
@@ 8,7 8,9 @@ on:
        type: boolean
      push_to_images:
        type: string
      version_suffix:
      version_prerelease:
        type: string
      version_metadata:
        type: string
      flavor:
        type: string


@@ 74,8 76,6 @@ jobs:
        if: ${{ inputs.push_to_images != '' }}
        with:
          images: ${{ inputs.push_to_images }}
          # Only tag with latest when ran against the latest stable branch
          # This needs to be updated after each minor version release
          flavor: ${{ inputs.flavor }}
          tags: ${{ inputs.tags }}
          labels: ${{ inputs.labels }}


@@ 83,7 83,9 @@ jobs:
      - uses: docker/build-push-action@v4
        with:
          context: .
          build-args: MASTODON_VERSION_SUFFIX=${{ inputs.version_suffix }}
          build-args: |
            MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }}
            MASTODON_VERSION_METADATA=${{ inputs.version_metadata }}
          platforms: ${{ inputs.platforms }}
          provenance: false
          builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}

M .github/workflows/build-nightly.yml => .github/workflows/build-nightly.yml +4 -5
@@ 16,9 16,9 @@ jobs:
        env:
          TZ: Etc/UTC
        run: |
          echo mastodon_version_suffix=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT
          echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT
    outputs:
      suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }}
      prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }}

  build-image:
    needs: compute-suffix


@@ 28,8 28,7 @@ jobs:
      use_native_arm64_builder: false
      push_to_images: |
        ghcr.io/${{ github.repository_owner }}/mastodon
      # The `-` is important here, result will be v4.1.2-nightly.2022-03-05
      version_suffix: -${{ needs.compute-suffix.outputs.suffix }}
      version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }}
      labels: |
        org.opencontainers.image.description=Nightly build image used for testing purposes
      flavor: |


@@ 37,5 36,5 @@ jobs:
      tags: |
        type=raw,value=edge
        type=raw,value=nightly
        type=schedule,pattern=${{ needs.compute-suffix.outputs.suffix }}
        type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }}
    secrets: inherit

M .github/workflows/build-push-pr.yml => .github/workflows/build-push-pr.yml +3 -3
@@ 21,9 21,9 @@ jobs:
        uses: actions/checkout@v3
      - id: version_vars
        run: |
          echo mastodon_version_suffix=+pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
          echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
    outputs:
      suffix: ${{ steps.version_vars.outputs.mastodon_version_suffix }}
      metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }}

  build-image:
    needs: compute-suffix


@@ 33,7 33,7 @@ jobs:
      use_native_arm64_builder: false
      push_to_images: |
        ghcr.io/${{ github.repository_owner }}/mastodon
      version_suffix: ${{ needs.compute-suffix.outputs.suffix }}
      version_metadata: ${{ needs.compute-suffix.outputs.metadata }}
      flavor: |
        latest=auto
      tags: |

M .github/workflows/build-releases.yml => .github/workflows/build-releases.yml +2 -0
@@ 16,6 16,8 @@ jobs:
      use_native_arm64_builder: false
      push_to_images: |
        ghcr.io/${{ github.repository_owner }}/mastodon
      # Only tag with latest when ran against the latest stable branch
      # This needs to be updated after each minor version release
      flavor: |
        latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') }}
      tags: |

M CHANGELOG.md => CHANGELOG.md +2 -1
@@ 101,7 101,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452))
- **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378))
- **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874))
- **Change local and federated timelines to be in a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247))
- **Change local and federated timelines to be tabs of a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247))
- **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034))
- **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751))
- **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310))


@@ 189,6 189,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073))
- **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218))
- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392))
- **Fix migration handler not updating lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24808))
- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500))
- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409))
- Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375))

M Dockerfile => Dockerfile +4 -4
@@ 42,8 42,8 @@ RUN apt-get update && \
FROM node:${NODE_VERSION}

# Use those args to specify your own version flags & suffixes
ARG MASTODON_VERSION_FLAGS=""
ARG MASTODON_VERSION_SUFFIX=""
ARG MASTODON_VERSION_PRERELEASE=""
ARG MASTODON_VERSION_METADATA=""

ARG UID="991"
ARG GID="991"


@@ 89,8 89,8 @@ ENV RAILS_ENV="production" \
    NODE_ENV="production" \
    RAILS_SERVE_STATIC_FILES="true" \
    BIND="0.0.0.0" \
    MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \
    MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}"
    MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
    MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}"

# Set the run user
USER mastodon

M Gemfile.lock => Gemfile.lock +12 -12
@@ 109,7 109,7 @@ GEM
      i18n (>= 1.6, < 2)
      minitest (>= 5.1)
      tzinfo (~> 2.0)
    addressable (2.8.4)
    addressable (2.8.5)
      public_suffix (>= 2.0.2, < 6.0)
    aes_key_wrap (1.1.0)
    airbrussh (1.4.1)


@@ 124,8 124,8 @@ GEM
    attr_required (1.0.1)
    awrence (1.2.1)
    aws-eventstream (1.2.0)
    aws-partitions (1.793.0)
    aws-sdk-core (3.180.3)
    aws-partitions (1.809.0)
    aws-sdk-core (3.181.0)
      aws-eventstream (~> 1, >= 1.0.2)
      aws-partitions (~> 1, >= 1.651.0)
      aws-sigv4 (~> 1.5)


@@ 133,8 133,8 @@ GEM
    aws-sdk-kms (1.71.0)
      aws-sdk-core (~> 3, >= 3.177.0)
      aws-sigv4 (~> 1.1)
    aws-sdk-s3 (1.132.1)
      aws-sdk-core (~> 3, >= 3.179.0)
    aws-sdk-s3 (1.133.0)
      aws-sdk-core (~> 3, >= 3.181.0)
      aws-sdk-kms (~> 1)
      aws-sigv4 (~> 1.6)
    aws-sigv4 (1.6.0)


@@ 203,7 203,7 @@ GEM
      activesupport
    cbor (0.5.9.6)
    charlock_holmes (0.7.7)
    chewy (7.3.3)
    chewy (7.3.4)
      activesupport (>= 5.2)
      elasticsearch (>= 7.12.0, < 7.14.0)
      elasticsearch-dsl


@@ 324,7 324,7 @@ GEM
      ruby-progressbar (~> 1.4)
    globalid (1.1.0)
      activesupport (>= 5.0)
    haml (6.1.1)
    haml (6.1.2)
      temple (>= 0.8.2)
      thor
      tilt


@@ 333,7 333,7 @@ GEM
      activesupport (>= 5.1)
      haml (>= 4.0.6)
      railties (>= 5.1)
    haml_lint (0.49.3)
    haml_lint (0.50.0)
      haml (>= 4.0, < 6.2)
      parallel (~> 1.10)
      rainbow


@@ 482,7 482,7 @@ GEM
    nokogiri (1.15.4)
      mini_portile2 (~> 2.8.2)
      racc (~> 1.4)
    oj (3.16.0)
    oj (3.16.1)
    omniauth (2.1.1)
      hashie (>= 3.4.6)
      rack (>= 2.2.3)


@@ 519,7 519,7 @@ GEM
    parslet (2.0.0)
    pastel (0.8.0)
      tty-color (~> 0.5)
    pg (1.5.3)
    pg (1.5.4)
    pghero (3.3.3)
      activerecord (>= 6)
    posix-spawn (0.3.15)


@@ 731,7 731,7 @@ GEM
      net-ssh (>= 2.8.0)
    stackprof (0.2.25)
    statsd-ruby (1.5.0)
    stoplight (3.0.1)
    stoplight (3.0.2)
      redlock (~> 1.0)
    strong_migrations (0.8.0)
      activerecord (>= 5.2)


@@ 795,7 795,7 @@ GEM
    webfinger (1.2.0)
      activesupport
      httpclient (>= 2.4)
    webmock (3.18.1)
    webmock (3.19.1)
      addressable (>= 2.8.0)
      crack (>= 0.3.2)
      hashdiff (>= 0.4.0, < 2.0.0)

M Procfile.dev => Procfile.dev +1 -1
@@ 1,4 1,4 @@
web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb
sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq
stream: env PORT=4000 yarn run start
webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0
webpack: bin/webpack-dev-server

M app/chewy/accounts_index.rb => app/chewy/accounts_index.rb +4 -3
@@ 21,12 21,13 @@ class AccountsIndex < Chewy::Index

    analyzer: {
      natural: {
        tokenizer: 'uax_url_email',
        tokenizer: 'standard',
        filter: %w(
          english_possessive_stemmer
          lowercase
          asciifolding
          cjk_width
          elision
          english_possessive_stemmer
          english_stop
          english_stemmer
        ),


@@ 62,6 63,6 @@ class AccountsIndex < Chewy::Index
    field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at })
    field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
    field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
    field(:text, type: 'text', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
    field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
  end
end

A app/chewy/public_statuses_index.rb => app/chewy/public_statuses_index.rb +56 -0
@@ 0,0 1,56 @@
# frozen_string_literal: true

class PublicStatusesIndex < Chewy::Index
  settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
    filter: {
      english_stop: {
        type: 'stop',
        stopwords: '_english_',
      },

      english_stemmer: {
        type: 'stemmer',
        language: 'english',
      },

      english_possessive_stemmer: {
        type: 'stemmer',
        language: 'possessive_english',
      },
    },

    analyzer: {
      verbatim: {
        tokenizer: 'uax_url_email',
        filter: %w(lowercase),
      },

      content: {
        tokenizer: 'standard',
        filter: %w(
          lowercase
          asciifolding
          cjk_width
          elision
          english_possessive_stemmer
          english_stop
          english_stemmer
        ),
      },
    },
  }

  index_scope ::Status.unscoped
                      .kept
                      .indexable
                      .includes(:media_attachments, :preloadable_poll, :preview_cards)

  root date_detection: false do
    field(:id, type: 'long')
    field(:account_id, type: 'long')
    field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
    field(:language, type: 'keyword')
    field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
    field(:created_at, type: 'date')
  end
end

M app/chewy/statuses_index.rb => app/chewy/statuses_index.rb +19 -40
@@ 1,31 1,38 @@
# frozen_string_literal: true

class StatusesIndex < Chewy::Index
  include FormattingHelper

  settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
    filter: {
      english_stop: {
        type: 'stop',
        stopwords: '_english_',
      },

      english_stemmer: {
        type: 'stemmer',
        language: 'english',
      },

      english_possessive_stemmer: {
        type: 'stemmer',
        language: 'possessive_english',
      },
    },

    analyzer: {
      content: {
      verbatim: {
        tokenizer: 'uax_url_email',
        filter: %w(lowercase),
      },

      content: {
        tokenizer: 'standard',
        filter: %w(
          english_possessive_stemmer
          lowercase
          asciifolding
          cjk_width
          elision
          english_possessive_stemmer
          english_stop
          english_stemmer
        ),


@@ 33,43 40,15 @@ class StatusesIndex < Chewy::Index
    },
  }

  # We do not use delete_if option here because it would call a method that we
  # expect to be called with crutches without crutches, causing n+1 queries
  index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll)

  crutch :mentions do |collection|
    data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
  end

  crutch :favourites do |collection|
    data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
  end

  crutch :reblogs do |collection|
    data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id)
    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
  end

  crutch :bookmarks do |collection|
    data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
  end

  crutch :votes do |collection|
    data = ::PollVote.joins(:poll).where(poll: { status_id: collection.map(&:id) }).where(account: Account.local).pluck(:status_id, :account_id)
    data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
  end
  index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preview_cards, :local_mentioned, :local_favorited, :local_reblogged, :local_bookmarked, preloadable_poll: :local_voters), delete_if: ->(status) { status.searchable_by.empty? }

  root date_detection: false do
    field :id, type: 'long'
    field :account_id, type: 'long'

    field :text, type: 'text', value: ->(status) { status.searchable_text } do
      field :stemmed, type: 'text', analyzer: 'content'
    end

    field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
    field(:id, type: 'long')
    field(:account_id, type: 'long')
    field(:text, type: 'text', analyzer: 'verbatim', value: ->(status) { status.searchable_text }) { field(:stemmed, type: 'text', analyzer: 'content') }
    field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by })
    field(:language, type: 'keyword')
    field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
    field(:created_at, type: 'date')
  end
end

A app/controllers/admin/software_updates_controller.rb => app/controllers/admin/software_updates_controller.rb +18 -0
@@ 0,0 1,18 @@
# frozen_string_literal: true

module Admin
  class SoftwareUpdatesController < BaseController
    before_action :check_enabled!

    def index
      authorize :software_update, :index?
      @software_updates = SoftwareUpdate.all.sort_by(&:gem_version)
    end

    private

    def check_enabled!
      not_found unless SoftwareUpdate.check_enabled?
    end
  end
end

M app/controllers/api/v1/accounts/credentials_controller.rb => app/controllers/api/v1/accounts/credentials_controller.rb +1 -0
@@ 30,6 30,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
      :bot,
      :discoverable,
      :hide_collections,
      :indexable,
      fields_attributes: [:name, :value]
    )
  end

M app/controllers/api/v1/statuses/translations_controller.rb => app/controllers/api/v1/statuses/translations_controller.rb +9 -1
@@ 8,7 8,15 @@ class Api::V1::Statuses::TranslationsController < Api::BaseController
  before_action :set_translation

  rescue_from TranslationService::NotConfiguredError, with: :not_found
  rescue_from TranslationService::UnexpectedResponseError, TranslationService::QuotaExceededError, TranslationService::TooManyRequestsError, with: :service_unavailable
  rescue_from TranslationService::UnexpectedResponseError, with: :service_unavailable

  rescue_from TranslationService::QuotaExceededError do
    render json: { error: I18n.t('translation.errors.quota_exceeded') }, status: 503
  end

  rescue_from TranslationService::TooManyRequestsError do
    render json: { error: I18n.t('translation.errors.too_many_requests') }, status: 503
  end

  def create
    render json: @translation, serializer: REST::TranslationSerializer

M app/controllers/api/v1/timelines/tag_controller.rb => app/controllers/api/v1/timelines/tag_controller.rb +5 -0
@@ 1,6 1,7 @@
# frozen_string_literal: true

class Api::V1::Timelines::TagController < Api::BaseController
  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
  before_action :load_tag
  after_action :insert_pagination_headers, unless: -> { @statuses.empty? }



@@ 12,6 13,10 @@ class Api::V1::Timelines::TagController < Api::BaseController

  private

  def require_auth?
    !Setting.timeline_preview
  end

  def load_tag
    @tag = Tag.find_normalized(params[:id])
  end

M app/controllers/application_controller.rb => app/controllers/application_controller.rb +1 -4
@@ 12,6 12,7 @@ class ApplicationController < ActionController::Base
  include DomainControlHelper
  include ThemingConcern
  include DatabaseHelper
  include AuthorizedFetchHelper

  helper_method :current_account
  helper_method :current_session


@@ 53,10 54,6 @@ class ApplicationController < ActionController::Base

  private

  def authorized_fetch_mode?
    ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.limited_federation_mode
  end

  def public_fetch_mode?
    !authorized_fetch_mode?
  end

M app/controllers/concerns/signature_verification.rb => app/controllers/concerns/signature_verification.rb +2 -0
@@ 119,6 119,8 @@ module SignatureVerification
  private

  def fail_with!(message, **options)
    Rails.logger.warn { "Signature verification failed: #{message}" }

    @signature_verification_failure_reason = { error: message }.merge(options)
    @signed_request_actor = nil
  end

M app/controllers/settings/privacy_controller.rb => app/controllers/settings/privacy_controller.rb +1 -1
@@ 18,7 18,7 @@ class Settings::PrivacyController < Settings::BaseController
  private

  def account_params
    params.require(:account).permit(:discoverable, :unlocked, :show_collections, settings: UserSettings.keys)
    params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys)
  end

  def set_account

A app/helpers/authorized_fetch_helper.rb => app/helpers/authorized_fetch_helper.rb +11 -0
@@ 0,0 1,11 @@
# frozen_string_literal: true

module AuthorizedFetchHelper
  def authorized_fetch_mode?
    ENV.fetch('AUTHORIZED_FETCH') { Setting.authorized_fetch } == 'true' || Rails.configuration.x.limited_federation_mode
  end

  def authorized_fetch_overridden?
    ENV.key?('AUTHORIZED_FETCH') || Rails.configuration.x.limited_federation_mode
  end
end

D app/javascript/core/public.js => app/javascript/core/public.js +0 -28
@@ 1,28 0,0 @@
//  This file will be loaded on public pages, regardless of theme.

import 'packs/public-path';

import { delegate } from '@rails/ujs';

const getProfileAvatarAnimationHandler = (swapTo) => {
  //animate avatar gifs on the profile page when moused over
  return ({ target }) => {
    const swapSrc = target.getAttribute(swapTo);
    //only change the img source if autoplay is off and the image src is actually different
    if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
      target.src = swapSrc;
    }
  };
};

delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));

delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));

delegate(document, '#account_header', 'change', ({ target }) => {
  const header = document.querySelector('.card .card__img img');
  const [file] = target.files || [];
  const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;

  header.src = url;
});

M app/javascript/core/settings.js => app/javascript/core/settings.js +0 -27
@@ 2,21 2,6 @@

import 'packs/public-path';
import { delegate } from '@rails/ujs';
import escapeTextContentForBrowser from 'escape-html';


import emojify from '../mastodon/features/emoji/emoji';

delegate(document, '#account_display_name', 'input', ({ target }) => {
  const name = document.querySelector('.card .display-name strong');
  if (name) {
    if (target.value) {
      name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
    } else {
      name.textContent = name.textContent = target.dataset.default;
    }
  }
});

delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
  const avatar = document.getElementById(target.id + '-preview');


@@ 26,18 11,6 @@ delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
  avatar.src = url;
});

delegate(document, '#account_locked', 'change', ({ target }) => {
  const lock = document.querySelector('.card .display-name i');

  if (lock) {
    if (target.checked) {
      delete lock.dataset.hidden;
    } else {
      lock.dataset.hidden = 'true';
    }
  }
});

delegate(document, '.input-copy input', 'click', ({ target }) => {
  target.focus();
  target.select();

M app/javascript/core/theme.yml => app/javascript/core/theme.yml +2 -2
@@ 13,8 13,8 @@ pack:
  mailer:
    filename: mailer.js
    stylesheet: true
  modal: public.js
  public: public.js
  modal:
  public:
  settings: settings.js
  sign_up:
  share:

M app/javascript/flavours/glitch/actions/interactions.js => app/javascript/flavours/glitch/actions/interactions.js +108 -5
@@ 1,11 1,16 @@
import api from '../api';
import api, { getLinks } from '../api';

import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer';

export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
export const REBLOG_FAIL    = 'REBLOG_FAIL';

export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';

export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
export const FAVOURITE_FAIL    = 'FAVOURITE_FAIL';


@@ 26,6 31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';

export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';

export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS';
export const PIN_FAIL    = 'PIN_FAIL';


@@ 259,8 268,10 @@ export function fetchReblogs(id) {
    dispatch(fetchReblogsRequest(id));

    api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');
      dispatch(importFetchedAccounts(response.data));
      dispatch(fetchReblogsSuccess(id, response.data));
      dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => {
      dispatch(fetchReblogsFail(id, error));
    });


@@ 274,17 285,62 @@ export function fetchReblogsRequest(id) {
  };
}

export function fetchReblogsSuccess(id, accounts) {
export function fetchReblogsSuccess(id, accounts, next) {
  return {
    type: REBLOGS_FETCH_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function fetchReblogsFail(id, error) {
  return {
    type: REBLOGS_FETCH_FAIL,
    id,
    error,
  };
}

export function expandReblogs(id) {
  return (dispatch, getState) => {
    const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
    if (url === null) {
      return;
    }

    dispatch(expandReblogsRequest(id));

    api(getState).get(url).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');

      dispatch(importFetchedAccounts(response.data));
      dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => dispatch(expandReblogsFail(id, error)));
  };
}

export function expandReblogsRequest(id) {
  return {
    type: REBLOGS_EXPAND_REQUEST,
    id,
  };
}

export function expandReblogsSuccess(id, accounts, next) {
  return {
    type: REBLOGS_EXPAND_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function expandReblogsFail(id, error) {
  return {
    type: REBLOGS_EXPAND_FAIL,
    id,
    error,
  };
}


@@ 294,8 350,10 @@ export function fetchFavourites(id) {
    dispatch(fetchFavouritesRequest(id));

    api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');
      dispatch(importFetchedAccounts(response.data));
      dispatch(fetchFavouritesSuccess(id, response.data));
      dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => {
      dispatch(fetchFavouritesFail(id, error));
    });


@@ 309,17 367,62 @@ export function fetchFavouritesRequest(id) {
  };
}

export function fetchFavouritesSuccess(id, accounts) {
export function fetchFavouritesSuccess(id, accounts, next) {
  return {
    type: FAVOURITES_FETCH_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function fetchFavouritesFail(id, error) {
  return {
    type: FAVOURITES_FETCH_FAIL,
    id,
    error,
  };
}

export function expandFavourites(id) {
  return (dispatch, getState) => {
    const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
    if (url === null) {
      return;
    }

    dispatch(expandFavouritesRequest(id));

    api(getState).get(url).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');

      dispatch(importFetchedAccounts(response.data));
      dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => dispatch(expandFavouritesFail(id, error)));
  };
}

export function expandFavouritesRequest(id) {
  return {
    type: FAVOURITES_EXPAND_REQUEST,
    id,
  };
}

export function expandFavouritesSuccess(id, accounts, next) {
  return {
    type: FAVOURITES_EXPAND_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function expandFavouritesFail(id, error) {
  return {
    type: FAVOURITES_EXPAND_FAIL,
    id,
    error,
  };
}

M app/javascript/flavours/glitch/components/status.jsx => app/javascript/flavours/glitch/components/status.jsx +1 -0
@@ 792,6 792,7 @@ class Status extends ImmutablePureComponent {
          tabIndex={0}
          data-featured={featured ? 'true' : null}
          aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
          data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}
        >
          {!muted && prepend}


M app/javascript/flavours/glitch/features/compose/components/search.jsx => app/javascript/flavours/glitch/features/compose/components/search.jsx +46 -8
@@ 1,11 1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';

import {
  injectIntl,
  FormattedMessage,
  defineMessages,
} from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';

import classNames from 'classnames';



@@ 52,6 48,16 @@ class Search extends PureComponent {
    options: [],
  };

  defaultOptions = [
    { label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
    { label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
    { label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
    { label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
    { label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
    { label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
    { label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
  ];

  setRef = c => {
    this.searchForm = c;
  };


@@ 100,7 106,7 @@ class Search extends PureComponent {

  handleKeyDown = (e) => {
    const { selectedOption } = this.state;
    const options = this._getOptions();
    const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();

    switch(e.key) {
    case 'Escape':


@@ 131,10 137,9 @@ class Search extends PureComponent {
      if (selectedOption === -1) {
        this._submit();
      } else if (options.length > 0) {
        options[selectedOption].action();
        options[selectedOption].action(e);
      }

      this._unfocus();
      break;
    case 'Delete':
      if (selectedOption > -1 && options.length > 0) {


@@ 161,6 166,7 @@ class Search extends PureComponent {

    router.history.push(`/tags/${query}`);
    onClickSearchResult(query, 'hashtag');
    this._unfocus();
  };

  handleAccountClick = () => {


@@ 171,6 177,7 @@ class Search extends PureComponent {

    router.history.push(`/@${query}`);
    onClickSearchResult(query, 'account');
    this._unfocus();
  };

  handleURLClick = () => {


@@ 178,6 185,7 @@ class Search extends PureComponent {
    const { onOpenURL } = this.props;

    onOpenURL(router.history);
    this._unfocus();
  };

  handleStatusSearch = () => {


@@ 196,6 204,8 @@ class Search extends PureComponent {
    } else if (search.get('type') === 'hashtag') {
      router.history.push(`/tags/${search.get('q')}`);
    }

    this._unfocus();
  };

  handleForgetRecentSearchClick = search => {


@@ 208,6 218,18 @@ class Search extends PureComponent {
    document.querySelector('.ui').parentElement.focus();
  }

  _insertText (text) {
    const { value, onChange } = this.props;

    if (value === '') {
      onChange(text);
    } else if (value[value.length - 1] === ' ') {
      onChange(`${value}${text}`);
    } else {
      onChange(`${value} ${text}`);
    }
  }

  _submit (type) {
    const { onSubmit, openInRoute } = this.props;
    const { router } = this.context;


@@ 217,6 239,8 @@ class Search extends PureComponent {
    if (openInRoute) {
      router.history.push('/search');
    }

    this._unfocus();
  }

  _getOptions () {


@@ 337,6 361,20 @@ class Search extends PureComponent {
              </div>
            </>
          )}

          {searchEnabled && (
            <>
              <h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>

              <div className='search__popout__menu'>
                {this.defaultOptions.map(({ key, label, action }, i) => (
                  <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
                    {label}
                  </button>
                ))}
              </div>
            </>
          )}
        </div>
      </div>
    );

M app/javascript/flavours/glitch/features/favourites/index.jsx => app/javascript/flavours/glitch/features/favourites/index.jsx +16 -9
@@ 8,7 8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';

import { fetchFavourites } from 'flavours/glitch/actions/interactions';
import { debounce } from 'lodash';

import { fetchFavourites, expandFavourites } from 'flavours/glitch/actions/interactions';
import ColumnHeader from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';


@@ 23,7 25,9 @@ const messages = defineMessages({
});

const mapStateToProps = (state, props) => ({
  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
  hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
  isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
});

class Favourites extends ImmutablePureComponent {


@@ 32,6 36,8 @@ class Favourites extends ImmutablePureComponent {
    params: PropTypes.object.isRequired,
    dispatch: PropTypes.func.isRequired,
    accountIds: ImmutablePropTypes.list,
    hasMore: PropTypes.bool,
    isLoading: PropTypes.bool,
    multiColumn: PropTypes.bool,
    intl: PropTypes.object.isRequired,
  };


@@ 42,12 48,6 @@ class Favourites extends ImmutablePureComponent {
    }
  }

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

  handleHeaderClick = () => {
    this.column.scrollTop();
  };


@@ 60,8 60,12 @@ class Favourites extends ImmutablePureComponent {
    this.props.dispatch(fetchFavourites(this.props.params.statusId));
  };

  handleLoadMore = debounce(() => {
    this.props.dispatch(expandFavourites(this.props.params.statusId));
  }, 300, { leading: true });

  render () {
    const { intl, accountIds, multiColumn } = this.props;
    const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;

    if (!accountIds) {
      return (


@@ 87,6 91,9 @@ class Favourites extends ImmutablePureComponent {
        />
        <ScrollableList
          scrollKey='favourites'
          onLoadMore={this.handleLoadMore}
          hasMore={hasMore}
          isLoading={isLoading}
          emptyMessage={emptyMessage}
          bindToDocument={!multiColumn}
        >

A app/javascript/flavours/glitch/features/home_timeline/components/critical_update_banner.tsx => app/javascript/flavours/glitch/features/home_timeline/components/critical_update_banner.tsx +26 -0
@@ 0,0 1,26 @@
import { FormattedMessage } from 'react-intl';

export const CriticalUpdateBanner = () => (
  <div className='warning-banner'>
    <div className='warning-banner__message'>
      <h1>
        <FormattedMessage
          id='home.pending_critical_update.title'
          defaultMessage='Critical security update available!'
        />
      </h1>
      <p>
        <FormattedMessage
          id='home.pending_critical_update.body'
          defaultMessage='Please update your Mastodon server as soon as possible!'
        />{' '}
        <a href='/admin/software_updates'>
          <FormattedMessage
            id='home.pending_critical_update.link'
            defaultMessage='See updates'
          />
        </a>
      </p>
    </div>
  </div>
);

M app/javascript/flavours/glitch/features/home_timeline/index.jsx => app/javascript/flavours/glitch/features/home_timeline/index.jsx +10 -4
@@ 14,7 14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
import { me } from 'flavours/glitch/initial_state';
import { me, criticalUpdatesPending } from 'flavours/glitch/initial_state';

import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines';


@@ 23,6 23,7 @@ import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container';

import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt';

const messages = defineMessages({


@@ 158,8 159,9 @@ class HomeTimeline extends PureComponent {
    const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
    const pinned = !!columnId;
    const { signedIn } = this.context.identity;
    const banners = [];

    let announcementsButton, banner;
    let announcementsButton;

    if (hasAnnouncements) {
      announcementsButton = (


@@ 174,8 176,12 @@ class HomeTimeline extends PureComponent {
      );
    }

    if (criticalUpdatesPending) {
      banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
    }

    if (tooSlow) {
      banner = <ExplorePrompt />;
      banners.push(<ExplorePrompt key='explore-prompt' />);
    }

    return (


@@ 197,7 203,7 @@ class HomeTimeline extends PureComponent {

        {signedIn ? (
          <StatusListContainer
            prepend={banner}
            prepend={banners}
            alwaysPrepend
            trackScroll={!pinned}
            scrollKey={`home_timeline-${columnId}`}

M app/javascript/flavours/glitch/features/reblogs/index.jsx => app/javascript/flavours/glitch/features/reblogs/index.jsx +16 -13
@@ 8,7 8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';

import { fetchReblogs } from 'flavours/glitch/actions/interactions';
import { debounce } from 'lodash';

import { fetchReblogs, expandReblogs } from 'flavours/glitch/actions/interactions';
import ColumnHeader from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';


@@ 16,17 18,15 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list';
import AccountContainer from 'flavours/glitch/containers/account_container';
import Column from 'flavours/glitch/features/ui/components/column';





const messages = defineMessages({
  heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' },
  refresh: { id: 'refresh', defaultMessage: 'Refresh' },
});

const mapStateToProps = (state, props) => ({
  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']),
  hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']),
  isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true),
});

class Reblogs extends ImmutablePureComponent {


@@ 35,6 35,8 @@ class Reblogs extends ImmutablePureComponent {
    params: PropTypes.object.isRequired,
    dispatch: PropTypes.func.isRequired,
    accountIds: ImmutablePropTypes.list,
    hasMore: PropTypes.bool,
    isLoading: PropTypes.bool,
    multiColumn: PropTypes.bool,
    intl: PropTypes.object.isRequired,
  };


@@ 45,12 47,6 @@ class Reblogs extends ImmutablePureComponent {
    }
  }

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

  handleHeaderClick = () => {
    this.column.scrollTop();
  };


@@ 63,8 59,12 @@ class Reblogs extends ImmutablePureComponent {
    this.props.dispatch(fetchReblogs(this.props.params.statusId));
  };

  handleLoadMore = debounce(() => {
    this.props.dispatch(expandReblogs(this.props.params.statusId));
  }, 300, { leading: true });

  render () {
    const { intl, accountIds, multiColumn } = this.props;
    const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;

    if (!accountIds) {
      return (


@@ 91,6 91,9 @@ class Reblogs extends ImmutablePureComponent {

        <ScrollableList
          scrollKey='reblogs'
          onLoadMore={this.handleLoadMore}
          hasMore={hasMore}
          isLoading={isLoading}
          emptyMessage={emptyMessage}
          bindToDocument={!multiColumn}
        >

M app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx => app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +8 -3
@@ 29,6 29,7 @@ const messages = defineMessages({
  about: { id: 'navigation_bar.about', defaultMessage: 'About' },
  search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
  advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
  openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
  app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
});



@@ 56,9 57,13 @@ class NavigationPanel extends Component {
      <div className='navigation-panel'>
        {transientSingleColumn && (
          <div className='navigation-panel__logo'>
            <a href={`/deck${location.pathname}`} className='button button--block'>
              {intl.formatMessage(messages.advancedInterface)}
            </a>
            <div class='switch-to-advanced'>
              {intl.formatMessage(messages.openedInClassicInterface)}
              {" "}
              <a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
                {intl.formatMessage(messages.advancedInterface)}
              </a>
            </div>
            <hr />
          </div>
        )}

M app/javascript/flavours/glitch/initial_state.js => app/javascript/flavours/glitch/initial_state.js +2 -0
@@ 100,6 100,7 @@ export const hasMultiColumnPath = initialPath === '/'
 * @typedef InitialState
 * @property {Record<string, Account>} accounts
 * @property {InitialStateLanguage[]} languages
 * @property {boolean=} critical_updates_pending
 * @property {InitialStateMeta} meta
 * @property {object} local_settings
 * @property {number} max_toot_chars


@@ 160,6 161,7 @@ export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');


M app/javascript/flavours/glitch/reducers/user_lists.js => app/javascript/flavours/glitch/reducers/user_lists.js +28 -2
@@ 44,8 44,18 @@ import {
  FEATURED_TAGS_FETCH_FAIL,
} from 'flavours/glitch/actions/featured_tags';
import {
  REBLOGS_FETCH_REQUEST,
  REBLOGS_FETCH_SUCCESS,
  REBLOGS_FETCH_FAIL,
  REBLOGS_EXPAND_REQUEST,
  REBLOGS_EXPAND_SUCCESS,
  REBLOGS_EXPAND_FAIL,
  FAVOURITES_FETCH_REQUEST,
  FAVOURITES_FETCH_SUCCESS,
  FAVOURITES_FETCH_FAIL,
  FAVOURITES_EXPAND_REQUEST,
  FAVOURITES_EXPAND_SUCCESS,
  FAVOURITES_EXPAND_FAIL,
} from 'flavours/glitch/actions/interactions';
import {
  MUTES_FETCH_REQUEST,


@@ 133,9 143,25 @@ export default function userLists(state = initialState, action) {
  case FOLLOWING_EXPAND_FAIL:
    return state.setIn(['following', action.id, 'isLoading'], false);
  case REBLOGS_FETCH_SUCCESS:
    return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
    return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next);
  case REBLOGS_EXPAND_SUCCESS:
    return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next);
  case REBLOGS_FETCH_REQUEST:
  case REBLOGS_EXPAND_REQUEST:
    return state.setIn(['reblogged_by', action.id, 'isLoading'], true);
  case REBLOGS_FETCH_FAIL:
  case REBLOGS_EXPAND_FAIL:
    return state.setIn(['reblogged_by', action.id, 'isLoading'], false);
  case FAVOURITES_FETCH_SUCCESS:
    return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
    return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next);
  case FAVOURITES_EXPAND_SUCCESS:
    return appendToList(state, ['favourited_by', action.id], action.accounts, action.next);
  case FAVOURITES_FETCH_REQUEST:
  case FAVOURITES_EXPAND_REQUEST:
    return state.setIn(['favourited_by', action.id, 'isLoading'], true);
  case FAVOURITES_FETCH_FAIL:
  case FAVOURITES_EXPAND_FAIL:
    return state.setIn(['favourited_by', action.id, 'isLoading'], false);
  case NOTIFICATIONS_UPDATE:
    return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
  case FOLLOW_REQUESTS_FETCH_SUCCESS:

M app/javascript/flavours/glitch/styles/accounts.scss => app/javascript/flavours/glitch/styles/accounts.scss +2 -0
@@ 192,6 192,8 @@
}

.account-role,
.information-badge,
.simple_form .overridden,
.simple_form .recommended,
.simple_form .not_recommended,
.simple_form .glitch_only {

M app/javascript/flavours/glitch/styles/admin.scss => app/javascript/flavours/glitch/styles/admin.scss +5 -0
@@ 143,6 143,11 @@ $content-width: 840px;
        }
      }

      .warning a {
        color: $gold-star;
        font-weight: 700;
      }

      .simple-navigation-active-leaf a {
        color: $primary-text-color;
        background-color: $ui-highlight-color;

M app/javascript/flavours/glitch/styles/components/columns.scss => app/javascript/flavours/glitch/styles/components/columns.scss +33 -1
@@ 228,6 228,22 @@ $ui-header-height: 55px;
  top: -48px;
}

.switch-to-advanced {
  color: $classic-primary-color;
  background-color: $classic-base-color;
  padding: 15px;
  border-radius: 4px;
  margin-top: 4px;
  margin-bottom: 12px;
  font-size: 13px;
  line-height: 18px;

  .switch-to-advanced__toggle {
    color: $ui-button-tertiary-color;
    font-weight: bold;
  }
}

.column-link {
  background: lighten($ui-base-color, 8%);
  color: $primary-text-color;


@@ 961,7 977,8 @@ $ui-header-height: 55px;
  }
}

.dismissable-banner {
.dismissable-banner,
.warning-banner {
  position: relative;
  margin: 10px;
  margin-bottom: 5px;


@@ 1039,6 1056,21 @@ $ui-header-height: 55px;
  }
}

.warning-banner {
  border: 1px solid $warning-red;
  background: rgba($warning-red, 0.15);

  &__message {
    h1 {
      color: $warning-red;
    }

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

.hashtag-header {
  border-bottom: 1px solid lighten($ui-base-color, 8%);
  padding: 15px;

M app/javascript/flavours/glitch/styles/components/search.scss => app/javascript/flavours/glitch/styles/components/search.scss +6 -0
@@ 25,6 25,12 @@
    }

    &__menu {
      margin-bottom: 20px;

      &:last-child {
        margin-bottom: 0;
      }

      &__message {
        color: $dark-text-color;
        padding: 0 10px;

M app/javascript/flavours/glitch/styles/components/single_column.scss => app/javascript/flavours/glitch/styles/components/single_column.scss +1 -0
@@ 120,6 120,7 @@

  .filter-form {
    display: flex;
    flex-wrap: wrap;
  }

  .autosuggest-textarea__textarea {

M app/javascript/flavours/glitch/styles/forms.scss => app/javascript/flavours/glitch/styles/forms.scss +3 -2
@@ 103,6 103,7 @@ code {
        }
      }

      .overridden,
      .recommended,
      .not_recommended,
      .glitch_only {


@@ 1187,14 1188,14 @@ code {
  }

  li:first-child .label {
    left: auto;
    inset-inline-start: 0;
    inset-inline-end: auto;
    text-align: start;
    transform: none;
  }

  li:last-child .label {
    left: auto;
    inset-inline-start: auto;
    inset-inline-end: 0;
    text-align: end;
    transform: none;

M app/javascript/flavours/glitch/styles/tables.scss => app/javascript/flavours/glitch/styles/tables.scss +5 -0
@@ 12,6 12,11 @@
    border-top: 1px solid $ui-base-color;
    text-align: start;
    background: darken($ui-base-color, 4%);

    &.critical {
      font-weight: 700;
      color: $gold-star;
    }
  }

  & > thead > tr > th {

M app/javascript/mastodon/actions/compose.js => app/javascript/mastodon/actions/compose.js +2 -1
@@ 84,6 84,7 @@ const messages = defineMessages({
  uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
  open: { id: 'compose.published.open', defaultMessage: 'Open' },
  published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
  saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
});

export const ensureComposeIsVisible = (getState, routerHistory) => {


@@ 246,7 247,7 @@ export function submitCompose(routerHistory) {
      }

      dispatch(showAlert({
        message: messages.published,
        message: statusId === null ? messages.published : messages.saved,
        action: messages.open,
        dismissAfter: 10000,
        onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),

M app/javascript/mastodon/actions/interactions.js => app/javascript/mastodon/actions/interactions.js +108 -5
@@ 1,11 1,16 @@
import api from '../api';
import api, { getLinks } from '../api';

import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer';

export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
export const REBLOG_FAIL    = 'REBLOG_FAIL';

export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';

export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
export const FAVOURITE_FAIL    = 'FAVOURITE_FAIL';


@@ 26,6 31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';

export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';

export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS';
export const PIN_FAIL    = 'PIN_FAIL';


@@ 273,8 282,10 @@ export function fetchReblogs(id) {
    dispatch(fetchReblogsRequest(id));

    api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');
      dispatch(importFetchedAccounts(response.data));
      dispatch(fetchReblogsSuccess(id, response.data));
      dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => {
      dispatch(fetchReblogsFail(id, error));
    });


@@ 288,17 299,62 @@ export function fetchReblogsRequest(id) {
  };
}

export function fetchReblogsSuccess(id, accounts) {
export function fetchReblogsSuccess(id, accounts, next) {
  return {
    type: REBLOGS_FETCH_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function fetchReblogsFail(id, error) {
  return {
    type: REBLOGS_FETCH_FAIL,
    id,
    error,
  };
}

export function expandReblogs(id) {
  return (dispatch, getState) => {
    const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
    if (url === null) {
      return;
    }

    dispatch(expandReblogsRequest(id));

    api(getState).get(url).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');

      dispatch(importFetchedAccounts(response.data));
      dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => dispatch(expandReblogsFail(id, error)));
  };
}

export function expandReblogsRequest(id) {
  return {
    type: REBLOGS_EXPAND_REQUEST,
    id,
  };
}

export function expandReblogsSuccess(id, accounts, next) {
  return {
    type: REBLOGS_EXPAND_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function expandReblogsFail(id, error) {
  return {
    type: REBLOGS_EXPAND_FAIL,
    id,
    error,
  };
}


@@ 308,8 364,10 @@ export function fetchFavourites(id) {
    dispatch(fetchFavouritesRequest(id));

    api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');
      dispatch(importFetchedAccounts(response.data));
      dispatch(fetchFavouritesSuccess(id, response.data));
      dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => {
      dispatch(fetchFavouritesFail(id, error));
    });


@@ 323,17 381,62 @@ export function fetchFavouritesRequest(id) {
  };
}

export function fetchFavouritesSuccess(id, accounts) {
export function fetchFavouritesSuccess(id, accounts, next) {
  return {
    type: FAVOURITES_FETCH_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function fetchFavouritesFail(id, error) {
  return {
    type: FAVOURITES_FETCH_FAIL,
    id,
    error,
  };
}

export function expandFavourites(id) {
  return (dispatch, getState) => {
    const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
    if (url === null) {
      return;
    }

    dispatch(expandFavouritesRequest(id));

    api(getState).get(url).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === 'next');

      dispatch(importFetchedAccounts(response.data));
      dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
      dispatch(fetchRelationships(response.data.map(item => item.id)));
    }).catch(error => dispatch(expandFavouritesFail(id, error)));
  };
}

export function expandFavouritesRequest(id) {
  return {
    type: FAVOURITES_EXPAND_REQUEST,
    id,
  };
}

export function expandFavouritesSuccess(id, accounts, next) {
  return {
    type: FAVOURITES_EXPAND_SUCCESS,
    id,
    accounts,
    next,
  };
}

export function expandFavouritesFail(id, error) {
  return {
    type: FAVOURITES_EXPAND_FAIL,
    id,
    error,
  };
}

M app/javascript/mastodon/components/hashtag_bar.tsx => app/javascript/mastodon/components/hashtag_bar.tsx +3 -3
@@ 10,8 10,8 @@ import { groupBy, minBy } from 'lodash';

import { getStatusContent } from './status_content';

// About two lines on desktop
const VISIBLE_HASHTAGS = 7;
// Fit on a single line on desktop
const VISIBLE_HASHTAGS = 3;

// Those types are not correct, they need to be replaced once this part of the state is typed
export type TagLike = Record<{ name: string }>;


@@ 210,7 210,7 @@ const HashtagBar: React.FC<{

  const revealedHashtags = expanded
    ? hashtags
    : hashtags.slice(0, VISIBLE_HASHTAGS - 1);
    : hashtags.slice(0, VISIBLE_HASHTAGS);

  return (
    <div className='hashtag-bar'>

M app/javascript/mastodon/components/status.jsx => app/javascript/mastodon/components/status.jsx +1 -1
@@ 550,7 550,7 @@ class Status extends ImmutablePureComponent {

    return (
      <HotKeys handlers={handlers}>
        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
          {prepend}

          <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>

M app/javascript/mastodon/features/compose/components/search.jsx => app/javascript/mastodon/features/compose/components/search.jsx +46 -5
@@ 1,7 1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';

import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';

import classNames from 'classnames';



@@ 45,6 45,16 @@ class Search extends PureComponent {
    options: [],
  };

  defaultOptions = [
    { label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
    { label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
    { label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
    { label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
    { label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
    { label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
    { label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
  ];

  setRef = c => {
    this.searchForm = c;
  };


@@ 70,7 80,7 @@ class Search extends PureComponent {

  handleKeyDown = (e) => {
    const { selectedOption } = this.state;
    const options = this._getOptions();
    const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();

    switch(e.key) {
    case 'Escape':


@@ 100,11 110,9 @@ class Search extends PureComponent {
      if (selectedOption === -1) {
        this._submit();
      } else if (options.length > 0) {
        options[selectedOption].action();
        options[selectedOption].action(e);
      }

      this._unfocus();

      break;
    case 'Delete':
      if (selectedOption > -1 && options.length > 0) {


@@ 147,6 155,7 @@ class Search extends PureComponent {

    router.history.push(`/tags/${query}`);
    onClickSearchResult(query, 'hashtag');
    this._unfocus();
  };

  handleAccountClick = () => {


@@ 157,6 166,7 @@ class Search extends PureComponent {

    router.history.push(`/@${query}`);
    onClickSearchResult(query, 'account');
    this._unfocus();
  };

  handleURLClick = () => {


@@ 164,6 174,7 @@ class Search extends PureComponent {
    const { value, onOpenURL } = this.props;

    onOpenURL(value, router.history);
    this._unfocus();
  };

  handleStatusSearch = () => {


@@ 182,6 193,8 @@ class Search extends PureComponent {
    } else if (search.get('type') === 'hashtag') {
      router.history.push(`/tags/${search.get('q')}`);
    }

    this._unfocus();
  };

  handleForgetRecentSearchClick = search => {


@@ 194,6 207,18 @@ class Search extends PureComponent {
    document.querySelector('.ui').parentElement.focus();
  }

  _insertText (text) {
    const { value, onChange } = this.props;

    if (value === '') {
      onChange(text);
    } else if (value[value.length - 1] === ' ') {
      onChange(`${value}${text}`);
    } else {
      onChange(`${value} ${text}`);
    }
  }

  _submit (type) {
    const { onSubmit, openInRoute } = this.props;
    const { router } = this.context;


@@ 203,6 228,8 @@ class Search extends PureComponent {
    if (openInRoute) {
      router.history.push('/search');
    }

    this._unfocus();
  }

  _getOptions () {


@@ 325,6 352,20 @@ class Search extends PureComponent {
              </div>
            </>
          )}

          {searchEnabled && (
            <>
              <h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>

              <div className='search__popout__menu'>
                {this.defaultOptions.map(({ key, label, action }, i) => (
                  <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
                    {label}
                  </button>
                ))}
              </div>
            </>
          )}
        </div>
      </div>
    );

M app/javascript/mastodon/features/favourites/index.jsx => app/javascript/mastodon/features/favourites/index.jsx +16 -9
@@ 8,7 8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';

import { fetchFavourites } from 'mastodon/actions/interactions';
import { debounce } from 'lodash';

import { fetchFavourites, expandFavourites } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon }  from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';


@@ 21,7 23,9 @@ const messages = defineMessages({
});

const mapStateToProps = (state, props) => ({
  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
  hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
  isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
});

class Favourites extends ImmutablePureComponent {


@@ 30,6 34,8 @@ class Favourites extends ImmutablePureComponent {
    params: PropTypes.object.isRequired,
    dispatch: PropTypes.func.isRequired,
    accountIds: ImmutablePropTypes.list,
    hasMore: PropTypes.bool,
    isLoading: PropTypes.bool,
    multiColumn: PropTypes.bool,
    intl: PropTypes.object.isRequired,
  };


@@ 40,18 46,16 @@ class Favourites extends ImmutablePureComponent {
    }
  }

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

  handleRefresh = () => {
    this.props.dispatch(fetchFavourites(this.props.params.statusId));
  };

  handleLoadMore = debounce(() => {
    this.props.dispatch(expandFavourites(this.props.params.statusId));
  }, 300, { leading: true });

  render () {
    const { intl, accountIds, multiColumn } = this.props;
    const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;

    if (!accountIds) {
      return (


@@ 75,6 79,9 @@ class Favourites extends ImmutablePureComponent {

        <ScrollableList
          scrollKey='favourites'
          onLoadMore={this.handleLoadMore}
          hasMore={hasMore}
          isLoading={isLoading}
          emptyMessage={emptyMessage}
          bindToDocument={!multiColumn}
        >

A app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx => app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx +26 -0
@@ 0,0 1,26 @@
import { FormattedMessage } from 'react-intl';

export const CriticalUpdateBanner = () => (
  <div className='warning-banner'>
    <div className='warning-banner__message'>
      <h1>
        <FormattedMessage
          id='home.pending_critical_update.title'
          defaultMessage='Critical security update available!'
        />
      </h1>
      <p>
        <FormattedMessage
          id='home.pending_critical_update.body'
          defaultMessage='Please update your Mastodon server as soon as possible!'
        />{' '}
        <a href='/admin/software_updates'>
          <FormattedMessage
            id='home.pending_critical_update.link'
            defaultMessage='See updates'
          />
        </a>
      </p>
    </div>
  </div>
);

M app/javascript/mastodon/features/home_timeline/index.jsx => app/javascript/mastodon/features/home_timeline/index.jsx +10 -4
@@ 14,7 14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
import { me } from 'mastodon/initial_state';
import { me, criticalUpdatesPending } from 'mastodon/initial_state';

import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines';


@@ 23,6 23,7 @@ import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container';

import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt';

const messages = defineMessages({


@@ 156,8 157,9 @@ class HomeTimeline extends PureComponent {
    const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
    const pinned = !!columnId;
    const { signedIn } = this.context.identity;
    const banners = [];

    let announcementsButton, banner;
    let announcementsButton;

    if (hasAnnouncements) {
      announcementsButton = (


@@ 173,8 175,12 @@ class HomeTimeline extends PureComponent {
      );
    }

    if (criticalUpdatesPending) {
      banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
    }

    if (tooSlow) {
      banner = <ExplorePrompt />;
      banners.push(<ExplorePrompt key='explore-prompt' />);
    }

    return (


@@ 196,7 202,7 @@ class HomeTimeline extends PureComponent {

        {signedIn ? (
          <StatusListContainer
            prepend={banner}
            prepend={banners}
            alwaysPrepend
            trackScroll={!pinned}
            scrollKey={`home_timeline-${columnId}`}

M app/javascript/mastodon/features/reblogs/index.jsx => app/javascript/mastodon/features/reblogs/index.jsx +17 -10
@@ 8,9 8,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';

import { debounce } from 'lodash';

import { Icon }  from 'mastodon/components/icon';

import { fetchReblogs } from '../../actions/interactions';
import { fetchReblogs, expandReblogs } from '../../actions/interactions';
import ColumnHeader from '../../components/column_header';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';


@@ 22,7 24,9 @@ const messages = defineMessages({
});

const mapStateToProps = (state, props) => ({
  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']),
  hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']),
  isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true),
});

class Reblogs extends ImmutablePureComponent {


@@ 31,6 35,8 @@ class Reblogs extends ImmutablePureComponent {
    params: PropTypes.object.isRequired,
    dispatch: PropTypes.func.isRequired,
    accountIds: ImmutablePropTypes.list,
    hasMore: PropTypes.bool,
    isLoading: PropTypes.bool,
    multiColumn: PropTypes.bool,
    intl: PropTypes.object.isRequired,
  };


@@ 39,20 45,18 @@ class Reblogs extends ImmutablePureComponent {
    if (!this.props.accountIds) {
      this.props.dispatch(fetchReblogs(this.props.params.statusId));
    }
  }

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

  handleRefresh = () => {
    this.props.dispatch(fetchReblogs(this.props.params.statusId));
  };

  handleLoadMore = debounce(() => {
    this.props.dispatch(expandReblogs(this.props.params.statusId));
  }, 300, { leading: true });

  render () {
    const { intl, accountIds, multiColumn } = this.props;
    const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;

    if (!accountIds) {
      return (


@@ 76,6 80,9 @@ class Reblogs extends ImmutablePureComponent {

        <ScrollableList
          scrollKey='reblogs'
          onLoadMore={this.handleLoadMore}
          hasMore={hasMore}
          isLoading={isLoading}
          emptyMessage={emptyMessage}
          bindToDocument={!multiColumn}
        >

M app/javascript/mastodon/features/ui/components/navigation_panel.jsx => app/javascript/mastodon/features/ui/components/navigation_panel.jsx +11 -5
@@ 31,6 31,7 @@ const messages = defineMessages({
  about: { id: 'navigation_bar.about', defaultMessage: 'About' },
  search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
  advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
  openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
});

class NavigationPanel extends Component {


@@ 57,12 58,17 @@ class NavigationPanel extends Component {
        <div className='navigation-panel__logo'>
          <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>

          {transientSingleColumn && (
            <a href={`/deck${location.pathname}`} className='button button--block'>
              {intl.formatMessage(messages.advancedInterface)}
            </a>
          {transientSingleColumn ? (
            <div class='switch-to-advanced'>
              {intl.formatMessage(messages.openedInClassicInterface)}
              {" "}
              <a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
                {intl.formatMessage(messages.advancedInterface)}
              </a>
            </div>
          ) : (
            <hr />
          )}
          <hr />
        </div>

        {signedIn && (

M app/javascript/mastodon/initial_state.js => app/javascript/mastodon/initial_state.js +2 -0
@@ 87,6 87,7 @@
 * @typedef InitialState
 * @property {Record<string, Account>} accounts
 * @property {InitialStateLanguage[]} languages
 * @property {boolean=} critical_updates_pending
 * @property {InitialStateMeta} meta
 * @property {number} max_toot_chars
 */


@@ 141,6 142,7 @@ export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
// @ts-expect-error
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +9 -0
@@ 137,6 137,7 @@
  "compose.language.search": "Search languages...",
  "compose.published.body": "Post published.",
  "compose.published.open": "Open",
  "compose.saved.body": "Post saved.",
  "compose_form.direct_message_warning_learn_more": "Learn more",
  "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
  "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",


@@ 309,6 310,9 @@
  "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:",
  "home.explore_prompt.title": "This is your home base within Mastodon.",
  "home.hide_announcements": "Hide announcements",
  "home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!",
  "home.pending_critical_update.link": "See updates",
  "home.pending_critical_update.title": "Critical security update available!",
  "home.show_announcements": "Show announcements",
  "interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
  "interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",


@@ 410,6 414,7 @@
  "navigation_bar.lists": "Lists",
  "navigation_bar.logout": "Logout",
  "navigation_bar.mutes": "Muted users",
  "navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
  "navigation_bar.personal": "Personal",
  "navigation_bar.pins": "Pinned posts",
  "navigation_bar.preferences": "Preferences",


@@ 585,8 590,12 @@
  "search.quick_action.open_url": "Open URL in Mastodon",
  "search.quick_action.status_search": "Posts matching {x}",
  "search.search_or_paste": "Search or paste URL",
  "search_popout.language_code": "ISO language code",
  "search_popout.options": "Search options",
  "search_popout.quick_actions": "Quick actions",
  "search_popout.recent": "Recent searches",
  "search_popout.specific_date": "specific date",
  "search_popout.user": "user",
  "search_results.accounts": "Profiles",
  "search_results.all": "All",
  "search_results.hashtags": "Hashtags",

M app/javascript/mastodon/locales/fr.json => app/javascript/mastodon/locales/fr.json +1 -0
@@ 409,6 409,7 @@
  "navigation_bar.lists": "Listes",
  "navigation_bar.logout": "Déconnexion",
  "navigation_bar.mutes": "Comptes masqués",
  "navigation_bar.opened_in_classic_interface": "Les messages, les comptes et les pages spécifiques sont ouvertes dans l’interface classique.",
  "navigation_bar.personal": "Personnel",
  "navigation_bar.pins": "Messages épinglés",
  "navigation_bar.preferences": "Préférences",

M app/javascript/mastodon/reducers/user_lists.js => app/javascript/mastodon/reducers/user_lists.js +28 -2
@@ 45,8 45,18 @@ import {
  BLOCKS_EXPAND_FAIL,
} from '../actions/blocks';
import {
  REBLOGS_FETCH_REQUEST,
  REBLOGS_FETCH_SUCCESS,
  REBLOGS_FETCH_FAIL,
  REBLOGS_EXPAND_REQUEST,
  REBLOGS_EXPAND_SUCCESS,
  REBLOGS_EXPAND_FAIL,
  FAVOURITES_FETCH_REQUEST,
  FAVOURITES_FETCH_SUCCESS,
  FAVOURITES_FETCH_FAIL,
  FAVOURITES_EXPAND_REQUEST,
  FAVOURITES_EXPAND_SUCCESS,
  FAVOURITES_EXPAND_FAIL,
} from '../actions/interactions';
import {
  MUTES_FETCH_REQUEST,


@@ 134,9 144,25 @@ export default function userLists(state = initialState, action) {
  case FOLLOWING_EXPAND_FAIL:
    return state.setIn(['following', action.id, 'isLoading'], false);
  case REBLOGS_FETCH_SUCCESS:
    return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
    return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next);
  case REBLOGS_EXPAND_SUCCESS:
    return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next);
  case REBLOGS_FETCH_REQUEST:
  case REBLOGS_EXPAND_REQUEST:
    return state.setIn(['reblogged_by', action.id, 'isLoading'], true);
  case REBLOGS_FETCH_FAIL:
  case REBLOGS_EXPAND_FAIL:
    return state.setIn(['reblogged_by', action.id, 'isLoading'], false);
  case FAVOURITES_FETCH_SUCCESS:
    return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
    return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next);
  case FAVOURITES_EXPAND_SUCCESS:
    return appendToList(state, ['favourited_by', action.id], action.accounts, action.next);
  case FAVOURITES_FETCH_REQUEST:
  case FAVOURITES_EXPAND_REQUEST:
    return state.setIn(['favourited_by', action.id, 'isLoading'], true);
  case FAVOURITES_FETCH_FAIL:
  case FAVOURITES_EXPAND_FAIL:
    return state.setIn(['favourited_by', action.id, 'isLoading'], false);
  case NOTIFICATIONS_UPDATE:
    return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
  case FOLLOW_REQUESTS_FETCH_SUCCESS:

M app/javascript/mastodon/test_setup.js => app/javascript/mastodon/test_setup.js +1 -1
@@ 1,1 1,1 @@
import '@testing-library/jest-dom/extend-expect';
import '@testing-library/jest-dom';

M app/javascript/packs/public.jsx => app/javascript/packs/public.jsx +9 -33
@@ 7,7 7,6 @@ import { defineMessages } from 'react-intl';

import { delegate }  from '@rails/ujs';
import axios from 'axios';
import { createBrowserHistory }  from 'history';
import { throttle } from 'lodash';

import { start } from '../mastodon/common';


@@ 31,23 30,6 @@ const messages = defineMessages({
function loaded() {
  const { messages: localeData } = getLocale();

  const scrollToDetailedStatus = () => {
    const history = createBrowserHistory();
    const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
    const location = history.location;

    if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
      detailedStatuses[0].scrollIntoView();
      history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
    }
  };

  const getEmojiAnimationHandler = (swapTo) => {
    return ({ target }) => {
      target.src = target.getAttribute(swapTo);
    };
  };

  const locale = document.documentElement.lang;

  const dateTimeFormat = new Intl.DateTimeFormat(locale, {


@@ 141,27 123,21 @@ function loaded() {
        const root = createRoot(content);
        root.render(<MediaContainer locale={locale} components={reactComponents} />);
        document.body.appendChild(content);
        scrollToDetailedStatus();
      })
      .catch(error => {
        console.error(error);
        scrollToDetailedStatus();
      });
  } else {
    scrollToDetailedStatus();
  }

  delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
    const username = document.getElementById('user_account_attributes_username');

    if (username.value && username.value.length > 0) {
      axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
        username.setCustomValidity(formatMessage(messages.usernameTaken));
  delegate(document, '#user_account_attributes_username', 'input', throttle(({ target }) => {
    if (target.value && target.value.length > 0) {
      axios.get('/api/v1/accounts/lookup', { params: { acct: target.value } }).then(() => {
        target.setCustomValidity(formatMessage(messages.usernameTaken));
      }).catch(() => {
        username.setCustomValidity('');
        target.setCustomValidity('');
      });
    } else {
      username.setCustomValidity('');
      target.setCustomValidity('');
    }
  }, 500, { leading: false, trailing: true }));



@@ 179,9 155,6 @@ function loaded() {
    }
  });

  delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
  delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));

  delegate(document, '.status__content__spoiler-link', 'click', function() {
    const statusEl = this.parentNode.parentNode;



@@ 230,6 203,9 @@ delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
  }
});

delegate(document, '.custom-emoji', 'mouseover', ({ target }) => target.src = target.getAttribute('data-original'));
delegate(document, '.custom-emoji', 'mouseout', ({ target }) => target.src = target.getAttribute('data-static'));

// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
delegate(document, '#registration_new_user,#new_user', 'submit', () => {

M app/javascript/styles/mastodon/accounts.scss => app/javascript/styles/mastodon/accounts.scss +2 -0
@@ 188,6 188,7 @@
}

.information-badge,
.simple_form .overridden,
.simple_form .recommended,
.simple_form .not_recommended {
  display: inline-block;


@@ 204,6 205,7 @@
}

.information-badge,
.simple_form .overridden,
.simple_form .recommended,
.simple_form .not_recommended {
  background-color: rgba($ui-secondary-color, 0.1);

M app/javascript/styles/mastodon/admin.scss => app/javascript/styles/mastodon/admin.scss +5 -0
@@ 143,6 143,11 @@ $content-width: 840px;
        }
      }

      .warning a {
        color: $gold-star;
        font-weight: 700;
      }

      .simple-navigation-active-leaf a {
        color: $primary-text-color;
        background-color: $ui-highlight-color;

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +52 -8
@@ 2381,6 2381,7 @@ $ui-header-height: 55px;

  .filter-form {
    display: flex;
    flex-wrap: wrap;
  }

  .autosuggest-textarea__textarea {


@@ 3270,6 3271,22 @@ $ui-header-height: 55px;
  border-color: $ui-highlight-color;
}

.switch-to-advanced {
  color: $classic-primary-color;
  background-color: $classic-base-color;
  padding: 15px;
  border-radius: 4px;
  margin-top: 4px;
  margin-bottom: 12px;
  font-size: 13px;
  line-height: 18px;

  .switch-to-advanced__toggle {
    color: $ui-button-tertiary-color;
    font-weight: bold;
  }
}

.column-link {
  background: lighten($ui-base-color, 8%);
  color: $primary-text-color;


@@ 4991,6 5008,12 @@ a.status-card {
    }

    &__menu {
      margin-bottom: 20px;

      &:last-child {
        margin-bottom: 0;
      }

      &__message {
        color: $dark-text-color;
        padding: 0 10px;


@@ 8837,7 8860,8 @@ noscript {
  }
}

.dismissable-banner {
.dismissable-banner,
.warning-banner {
  position: relative;
  margin: 10px;
  margin-bottom: 5px;


@@ 8915,6 8939,21 @@ noscript {
  }
}

.warning-banner {
  border: 1px solid $warning-red;
  background: rgba($warning-red, 0.15);

  &__message {
    h1 {
      color: $warning-red;
    }

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

.image {
  position: relative;
  overflow: hidden;


@@ 9302,19 9341,24 @@ noscript {
  display: flex;
  flex-wrap: wrap;
  font-size: 14px;
  line-height: 18px;
  gap: 4px;
  color: $darker-text-color;

  a {
    display: inline-flex;
    color: $dark-text-color;
    color: inherit;
    text-decoration: none;

    &:hover {
      text-decoration: none;

      span {
        text-decoration: underline;
      }
    &:hover span {
      text-decoration: underline;
    }
  }

  .link-button {
    color: inherit;
    font-size: inherit;
    line-height: inherit;
    padding: 0;
  }
}

M app/javascript/styles/mastodon/forms.scss => app/javascript/styles/mastodon/forms.scss +3 -2
@@ 103,6 103,7 @@ code {
        }
      }

      .overridden,
      .recommended,
      .not_recommended {
        position: absolute;


@@ 1185,14 1186,14 @@ code {
  }

  li:first-child .label {
    left: auto;
    inset-inline-start: 0;
    inset-inline-end: auto;
    text-align: start;
    transform: none;
  }

  li:last-child .label {
    left: auto;
    inset-inline-start: auto;
    inset-inline-end: 0;
    text-align: end;
    transform: none;

M app/javascript/styles/mastodon/tables.scss => app/javascript/styles/mastodon/tables.scss +5 -0
@@ 12,6 12,11 @@
    border-top: 1px solid $ui-base-color;
    text-align: start;
    background: darken($ui-base-color, 4%);

    &.critical {
      font-weight: 700;
      color: $gold-star;
    }
  }

  & > thead > tr > th {

M app/lib/admin/metrics/dimension/software_versions_dimension.rb => app/lib/admin/metrics/dimension/software_versions_dimension.rb +17 -1
@@ 10,7 10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
  protected

  def perform_query
    [mastodon_version, ruby_version, postgresql_version, redis_version]
    [mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version].compact
  end

  def mastodon_version


@@ 57,6 57,22 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
    }
  end

  def elasticsearch_version
    return unless Chewy.enabled?

    client_info = Chewy.client.info
    version = client_info.dig('version', 'number')

    {
      key: 'elasticsearch',
      human_key: client_info.dig('version', 'distribution') == 'opensearch' ? 'OpenSearch' : 'Elasticsearch',
      value: version,
      human_value: version,
    }
  rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
    nil
  end

  def redis_info
    @redis_info ||= if redis.is_a?(Redis::Namespace)
                      redis.redis.info

M app/lib/admin/system_check.rb => app/lib/admin/system_check.rb +1 -0
@@ 2,6 2,7 @@

class Admin::SystemCheck
  ACTIVE_CHECKS = [
    Admin::SystemCheck::SoftwareVersionCheck,
    Admin::SystemCheck::MediaPrivacyCheck,
    Admin::SystemCheck::DatabaseSchemaCheck,
    Admin::SystemCheck::SidekiqProcessCheck,

M app/lib/admin/system_check/elasticsearch_check.rb => app/lib/admin/system_check/elasticsearch_check.rb +2 -1
@@ 6,6 6,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
    AccountsIndex,
    TagsIndex,
    StatusesIndex,
    PublicStatusesIndex,
  ].freeze

  def skip?


@@ 85,7 86,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck

  def mismatched_indexes
    @mismatched_indexes ||= INDEXES.filter_map do |klass|
      klass.index_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash
      klass.base_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash
    end
  end


A app/lib/admin/system_check/software_version_check.rb => app/lib/admin/system_check/software_version_check.rb +27 -0
@@ 0,0 1,27 @@
# frozen_string_literal: true

class Admin::SystemCheck::SoftwareVersionCheck < Admin::SystemCheck::BaseCheck
  include RoutingHelper

  def skip?
    !current_user.can?(:view_devops) || !SoftwareUpdate.check_enabled?
  end

  def pass?
    software_updates.empty?
  end

  def message
    if software_updates.any?(&:urgent?)
      Admin::SystemCheck::Message.new(:software_version_critical_check, nil, admin_software_updates_path, true)
    else
      Admin::SystemCheck::Message.new(:software_version_patch_check, nil, admin_software_updates_path)
    end
  end

  private

  def software_updates
    @software_updates ||= SoftwareUpdate.pending_to_a.filter { |update| update.urgent? || update.patch_type? }
  end
end

M app/lib/importer/accounts_index_importer.rb => app/lib/importer/accounts_index_importer.rb +3 -3
@@ 4,10 4,10 @@ class Importer::AccountsIndexImporter < Importer::BaseImporter
  def import!
    scope.includes(:account_stat).find_in_batches(batch_size: @batch_size) do |tmp|
      in_work_unit(tmp) do |accounts|
        bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: accounts).bulk_body
        bulk = build_bulk_body(accounts)

        indexed = bulk.count { |entry| entry[:index] }
        deleted = bulk.count { |entry| entry[:delete] }
        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)


M app/lib/importer/base_importer.rb => app/lib/importer/base_importer.rb +8 -0
@@ 68,6 68,14 @@ class Importer::BaseImporter

  protected

  def build_bulk_body(to_import)
    # Specialize `Chewy::Index::Import::BulkBuilder#bulk_body` to avoid a few
    # inefficiencies, as none of our fields or join fields and we do not need
    # `BulkBuilder`'s versatility.
    crutches = Chewy::Index::Crutch::Crutches.new index, to_import
    to_import.map { |object| { index: { _id: object.id, data: index.compose(object, crutches, fields: []) } } }
  end

  def in_work_unit(...)
    work_unit = Concurrent::Promises.future_on(@executor, ...)


M app/lib/importer/instances_index_importer.rb => app/lib/importer/instances_index_importer.rb +3 -3
@@ 4,10 4,10 @@ class Importer::InstancesIndexImporter < Importer::BaseImporter
  def import!
    index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
      in_work_unit(tmp) do |instances|
        bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: instances).bulk_body
        bulk = build_bulk_body(instances)

        indexed = bulk.count { |entry| entry[:index] }
        deleted = bulk.count { |entry| entry[:delete] }
        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)


A app/lib/importer/public_statuses_index_importer.rb => app/lib/importer/public_statuses_index_importer.rb +32 -0
@@ 0,0 1,32 @@
# frozen_string_literal: true

class Importer::PublicStatusesIndexImporter < Importer::BaseImporter
  def import!
    scope.select(:id).find_in_batches(batch_size: @batch_size) do |batch|
      in_work_unit(batch.pluck(:id)) do |status_ids|
        bulk = ActiveRecord::Base.connection_pool.with_connection do
          build_bulk_body(index.adapter.default_scope.where(id: status_ids))
        end

        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

        [indexed, deleted]
      end
    end

    wait!
  end

  private

  def index
    PublicStatusesIndex
  end

  def scope
    Status.indexable
  end
end

M app/lib/importer/statuses_index_importer.rb => app/lib/importer/statuses_index_importer.rb +14 -21
@@ 13,32 13,25 @@ class Importer::StatusesIndexImporter < Importer::BaseImporter

      scope.find_in_batches(batch_size: @batch_size) do |tmp|
        in_work_unit(tmp.map(&:status_id)) do |status_ids|
          bulk = ActiveRecord::Base.connection_pool.with_connection do
            Chewy::Index::Import::BulkBuilder.new(index, to_index: Status.includes(:media_attachments, :preloadable_poll).where(id: status_ids)).bulk_body
          end

          indexed = 0
          deleted = 0

          # We can't use the delete_if proc to do the filtering because delete_if
          # is called before rendering the data and we need to filter based
          # on the results of the filter, so this filtering happens here instead
          bulk.map! do |entry|
            new_entry = if entry[:index] && entry.dig(:index, :data, 'searchable_by').blank?
                          { delete: entry[:index].except(:data) }
                        else
                          entry
                        end

            if new_entry[:index]
              indexed += 1
            else
              deleted += 1
          bulk = ActiveRecord::Base.connection_pool.with_connection do
            to_index = index.adapter.default_scope.where(id: status_ids)
            crutches = Chewy::Index::Crutch::Crutches.new index, to_index
            to_index.map do |object|
              # This is unlikely to happen, but the post may have been
              # un-interacted with since it was queued for indexing
              if object.searchable_by.empty?
                deleted += 1
                { delete: { _id: object.id } }
              else
                { index: { _id: object.id, data: index.compose(object, crutches, fields: []) } }
              end
            end

            new_entry
          end

          indexed = bulk.size - deleted

          Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

          [indexed, deleted]

M app/lib/importer/tags_index_importer.rb => app/lib/importer/tags_index_importer.rb +3 -3
@@ 4,10 4,10 @@ class Importer::TagsIndexImporter < Importer::BaseImporter
  def import!
    index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
      in_work_unit(tmp) do |tags|
        bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: tags).bulk_body
        bulk = build_bulk_body(tags)

        indexed = bulk.count { |entry| entry[:index] }
        deleted = bulk.count { |entry| entry[:delete] }
        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)


M app/lib/plain_text_formatter.rb => app/lib/plain_text_formatter.rb +4 -7
@@ 1,8 1,6 @@
# frozen_string_literal: true

class PlainTextFormatter
  include ActionView::Helpers::TextHelper

  NEWLINE_TAGS_RE = %r{(<br />|<br>|</p>)+}

  attr_reader :text, :local


@@ 18,7 16,10 @@ class PlainTextFormatter
    if local?
      text
    else
      html_entities.decode(strip_tags(insert_newlines)).chomp
      node = Nokogiri::HTML.fragment(insert_newlines)
      # Elements that are entirely removed with our Sanitize config
      node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove
      node.text.chomp
    end
  end



@@ 27,8 28,4 @@ class PlainTextFormatter
  def insert_newlines
    text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" }
  end

  def html_entities
    HTMLEntities.new
  end
end

M app/lib/search_query_parser.rb => app/lib/search_query_parser.rb +2 -2
@@ 6,10 6,10 @@ class SearchQueryParser < Parslet::Parser
  rule(:colon)     { str(':') }
  rule(:space)     { match('\s').repeat(1) }
  rule(:operator)  { (str('+') | str('-')).as(:operator) }
  rule(:prefix)    { (term >> colon).as(:prefix) }
  rule(:prefix)    { term >> colon }
  rule(:shortcode) { (colon >> term >> colon.maybe).as(:shortcode) }
  rule(:phrase)    { (quote >> (term >> space.maybe).repeat >> quote).as(:phrase) }
  rule(:clause)    { (prefix.maybe >> operator.maybe >> (phrase | term | shortcode)).as(:clause) }
  rule(:clause)    { (operator.maybe >> prefix.maybe.as(:prefix) >> (phrase | term | shortcode)).as(:clause) | prefix.as(:clause) | quote.as(:junk) }
  rule(:query)     { (clause >> space.maybe).repeat.as(:query) }
  root(:query)
end

M app/lib/search_query_transformer.rb => app/lib/search_query_transformer.rb +108 -52
@@ 1,58 1,42 @@
# frozen_string_literal: true

class SearchQueryTransformer < Parslet::Transform
  SUPPORTED_PREFIXES = %w(
    has
    is
    language
    from
    before
    after
    during
  ).freeze

  class Query
    attr_reader :should_clauses, :must_not_clauses, :must_clauses, :filter_clauses
    attr_reader :must_not_clauses, :must_clauses, :filter_clauses

    def initialize(clauses)
      grouped = clauses.chunk(&:operator).to_h
      @should_clauses = grouped.fetch(:should, [])
      grouped = clauses.compact.chunk(&:operator).to_h
      @must_not_clauses = grouped.fetch(:must_not, [])
      @must_clauses = grouped.fetch(:must, [])
      @filter_clauses = grouped.fetch(:filter, [])
    end

    def apply(search)
      should_clauses.each { |clause| search = search.query.should(clause_to_query(clause)) }
      must_clauses.each { |clause| search = search.query.must(clause_to_query(clause)) }
      must_not_clauses.each { |clause| search = search.query.must_not(clause_to_query(clause)) }
      filter_clauses.each { |clause| search = search.filter(**clause_to_filter(clause)) }
      must_clauses.each { |clause| search = search.query.must(clause.to_query) }
      must_not_clauses.each { |clause| search = search.query.must_not(clause.to_query) }
      filter_clauses.each { |clause| search = search.filter(**clause.to_query) }
      search.query.minimum_should_match(1)
    end

    private

    def clause_to_query(clause)
      case clause
      when TermClause
        { multi_match: { type: 'most_fields', query: clause.term, fields: ['text', 'text.stemmed'] } }
      when PhraseClause
        { match_phrase: { text: { query: clause.phrase } } }
      else
        raise "Unexpected clause type: #{clause}"
      end
    end

    def clause_to_filter(clause)
      case clause
      when PrefixClause
        { term: { clause.filter => clause.term } }
      else
        raise "Unexpected clause type: #{clause}"
      end
    end
  end

  class Operator
    class << self
      def symbol(str)
        case str
        when '+'
        when '+', nil
          :must
        when '-'
          :must_not
        when nil
          :should
        else
          raise "Unknown operator: #{str}"
        end


@@ 61,61 45,133 @@ class SearchQueryTransformer < Parslet::Transform
  end

  class TermClause
    attr_reader :prefix, :operator, :term
    attr_reader :operator, :term

    def initialize(prefix, operator, term)
      @prefix = prefix
    def initialize(operator, term)
      @operator = Operator.symbol(operator)
      @term = term
    end

    def to_query
      { multi_match: { type: 'most_fields', query: @term, fields: ['text', 'text.stemmed'], operator: 'and' } }
    end
  end

  class PhraseClause
    attr_reader :prefix, :operator, :phrase
    attr_reader :operator, :phrase

    def initialize(prefix, operator, phrase)
      @prefix = prefix
    def initialize(operator, phrase)
      @operator = Operator.symbol(operator)
      @phrase = phrase
    end

    def to_query
      { match_phrase: { text: { query: @phrase } } }
    end
  end

  class PrefixClause
    attr_reader :filter, :operator, :term
    attr_reader :operator, :prefix, :term

    def initialize(prefix, term)
    def initialize(prefix, operator, term, options = {})
      @prefix = prefix
      @negated = operator == '-'
      @options = options
      @operator = :filter

      case prefix
      when 'has', 'is'
        @filter = :properties
        @type = :term
        @term = term
      when 'language'
        @filter = :language
        @type = :term
        @term = language_code_from_term(term)
      when 'from'
        @filter = :account_id
        @type = :term
        @term = account_id_from_term(term)
      when 'before'
        @filter = :created_at
        @type = :range
        @term = { lt: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
      when 'after'
        @filter = :created_at
        @type = :range
        @term = { gt: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
      when 'during'
        @filter = :created_at
        @type = :range
        @term = { gte: term, lte: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
      else
        raise "Unknown prefix: #{prefix}"
      end
    end

        username, domain = term.gsub(/\A@/, '').split('@')
        domain           = nil if TagManager.instance.local_domain?(domain)
        account          = Account.find_remote!(username, domain)

        @term = account.id
    def to_query
      if @negated
        { bool: { must_not: { @type => { @filter => @term } } } }
      else
        raise Mastodon::SyntaxError
        { @type => { @filter => @term } }
      end
    end

    private

    def account_id_from_term(term)
      return @options[:current_account]&.id || -1 if term == 'me'

      username, domain = term.gsub(/\A@/, '').split('@')
      domain = nil if TagManager.instance.local_domain?(domain)
      account = Account.find_remote(username, domain)

      # If the account is not found, we want to return empty results, so return
      # an ID that does not exist
      account&.id || -1
    end

    def language_code_from_term(term)
      language_code = term

      return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)

      language_code = term.downcase

      return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)

      language_code = term.split(/[_-]/).first.downcase

      return language_code if LanguagesHelper::SUPPORTED_LOCALES.key?(language_code.to_sym)

      term
    end
  end

  rule(clause: subtree(:clause)) do
    prefix   = clause[:prefix][:term].to_s if clause[:prefix]
    operator = clause[:operator]&.to_s

    if clause[:prefix]
      PrefixClause.new(prefix, clause[:term].to_s)
    if clause[:prefix] && SUPPORTED_PREFIXES.include?(prefix)
      PrefixClause.new(prefix, operator, clause[:term].to_s, current_account: current_account)
    elsif clause[:prefix]
      TermClause.new(operator, "#{prefix} #{clause[:term]}")
    elsif clause[:term]
      TermClause.new(prefix, operator, clause[:term].to_s)
      TermClause.new(operator, clause[:term].to_s)
    elsif clause[:shortcode]
      TermClause.new(prefix, operator, ":#{clause[:term]}:")
      TermClause.new(operator, ":#{clause[:term]}:")
    elsif clause[:phrase]
      PhraseClause.new(prefix, operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s)
      PhraseClause.new(operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s)
    else
      raise "Unexpected clause type: #{clause}"
    end
  end

  rule(query: sequence(:clauses)) { Query.new(clauses) }
  rule(junk: subtree(:junk)) do
    nil
  end

  rule(query: sequence(:clauses)) do
    Query.new(clauses)
  end
end

M app/lib/vacuum/statuses_vacuum.rb => app/lib/vacuum/statuses_vacuum.rb +6 -3
@@ 20,7 20,10 @@ class Vacuum::StatusesVacuum
      statuses.direct_visibility
              .includes(mentions: :account)
              .find_each(&:unlink_from_conversations!)
      remove_from_search_index(statuses.ids) if Chewy.enabled?
      if Chewy.enabled?
        remove_from_index(statuses.ids, 'chewy:queue:StatusesIndex')
        remove_from_index(statuses.ids, 'chewy:queue:PublicStatusesIndex')
      end

      # Foreign keys take care of most associated records for us.
      # Media attachments will be orphaned.


@@ 38,7 41,7 @@ class Vacuum::StatusesVacuum
    Mastodon::Snowflake.id_at(@retention_period.ago, with_random: false)
  end

  def remove_from_search_index(status_ids)
    with_redis { |redis| redis.sadd('chewy:queue:StatusesIndex', status_ids) }
  def remove_from_index(status_ids, index)
    with_redis { |redis| redis.sadd(index, status_ids) }
  end
end

M app/mailers/admin_mailer.rb => app/mailers/admin_mailer.rb +16 -0
@@ 45,6 45,22 @@ class AdminMailer < ApplicationMailer
    end
  end

  def new_software_updates
    locale_for_account(@me) do
      mail subject: default_i18n_subject(instance: @instance)
    end
  end

  def new_critical_software_updates
    headers['Priority'] = 'urgent'
    headers['X-Priority'] = '1'
    headers['Importance'] = 'high'

    locale_for_account(@me) do
      mail subject: default_i18n_subject(instance: @instance)
    end
  end

  private

  def process_params

M app/models/account.rb => app/models/account.rb +1 -0
@@ 82,6 82,7 @@ class Account < ApplicationRecord
  include DomainMaterializable
  include AccountMerging
  include AccountSearch
  include AccountStatusesSearch

  MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
  MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i

A app/models/concerns/account_statuses_search.rb => app/models/concerns/account_statuses_search.rb +44 -0
@@ 0,0 1,44 @@
# frozen_string_literal: true

module AccountStatusesSearch
  extend ActiveSupport::Concern

  included do
    after_update_commit :enqueue_update_public_statuses_index, if: :saved_change_to_indexable?
    after_destroy_commit :enqueue_remove_from_public_statuses_index, if: :indexable?
  end

  def enqueue_update_public_statuses_index
    if indexable?
      enqueue_add_to_public_statuses_index
    else
      enqueue_remove_from_public_statuses_index
    end
  end

  def enqueue_add_to_public_statuses_index
    return unless Chewy.enabled?

    AddToPublicStatusesIndexWorker.perform_async(id)
  end

  def enqueue_remove_from_public_statuses_index
    return unless Chewy.enabled?

    RemoveFromPublicStatusesIndexWorker.perform_async(id)
  end

  def add_to_public_statuses_index!
    return unless Chewy.enabled?

    statuses.without_reblogs.where(visibility: :public).find_in_batches do |batch|
      PublicStatusesIndex.import(batch)
    end
  end

  def remove_from_public_statuses_index!
    return unless Chewy.enabled?

    PublicStatusesIndex.filter(term: { account_id: id }).delete_all
  end
end

A app/models/concerns/status_search_concern.rb => app/models/concerns/status_search_concern.rb +48 -0
@@ 0,0 1,48 @@
# frozen_string_literal: true

module StatusSearchConcern
  extend ActiveSupport::Concern

  included do
    scope :indexable, -> { without_reblogs.where(visibility: :public).joins(:account).where(account: { indexable: true }) }
  end

  def searchable_by
    @searchable_by ||= begin
      ids = []

      ids << account_id if local?

      ids += local_mentioned.pluck(:id)
      ids += local_favorited.pluck(:id)
      ids += local_reblogged.pluck(:id)
      ids += local_bookmarked.pluck(:id)
      ids += preloadable_poll.local_voters.pluck(:id) if preloadable_poll.present?

      ids.uniq
    end
  end

  def searchable_text
    [
      spoiler_text,
      FormattingHelper.extract_status_plain_text(self),
      preloadable_poll&.options&.join("\n\n"),
      ordered_media_attachments.map(&:description).join("\n\n"),
    ].compact.join("\n\n")
  end

  def searchable_properties
    [].tap do |properties|
      properties << 'image' if ordered_media_attachments.any?(&:image?)
      properties << 'video' if ordered_media_attachments.any?(&:video?)
      properties << 'audio' if ordered_media_attachments.any?(&:audio?)
      properties << 'media' if with_media?
      properties << 'poll' if with_poll?
      properties << 'link' if with_preview_card?
      properties << 'embed' if preview_cards.any?(&:video?)
      properties << 'sensitive' if sensitive?
      properties << 'reply' if reply?
    end
  end
end

M app/models/form/admin_settings.rb => app/models/form/admin_settings.rb +10 -0
@@ 3,6 3,8 @@
class Form::AdminSettings
  include ActiveModel::Model

  include AuthorizedFetchHelper

  KEYS = %i(
    site_contact_username
    site_contact_email


@@ 42,6 44,7 @@ class Form::AdminSettings
    backups_retention_period
    status_page_url
    captcha_enabled
    authorized_fetch
  ).freeze

  INTEGER_KEYS = %i(


@@ 66,6 69,7 @@ class Form::AdminSettings
    noindex
    require_invite_text
    captcha_enabled
    authorized_fetch
  ).freeze

  UPLOAD_KEYS = %i(


@@ 77,6 81,10 @@ class Form::AdminSettings
    flavour_and_skin
  ).freeze

  OVERRIDEN_SETTINGS = {
    authorized_fetch: :authorized_fetch_mode?,
  }.freeze

  attr_accessor(*KEYS)

  validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) }


@@ 96,6 104,8 @@ class Form::AdminSettings

      stored_value = if UPLOAD_KEYS.include?(key)
                       SiteUpload.where(var: key).first_or_initialize(var: key)
                     elsif OVERRIDEN_SETTINGS.include?(key)
                       public_send(OVERRIDEN_SETTINGS[key])
                     else
                       Setting.public_send(key)
                     end

M app/models/media_attachment.rb => app/models/media_attachment.rb +9 -10
@@ 44,6 44,7 @@ class MediaAttachment < ApplicationRecord

  MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px
  MAX_VIDEO_FRAME_RATE   = 120
  MAX_VIDEO_FRAMES       = 36_000 # Approx. 5 minutes at 120 fps

  IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze
  VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze


@@ 98,17 99,15 @@ class MediaAttachment < ApplicationRecord
    convert_options: {
      output: {
        'loglevel' => 'fatal',
        'movflags' => 'faststart',
        'pix_fmt' => 'yuv420p',
        'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
        'vsync' => 'cfr',
        'preset' => 'veryfast',
        'movflags' => 'faststart', # Move metadata to start of file so playback can begin before download finishes
        'pix_fmt' => 'yuv420p', # Ensure color space for cross-browser compatibility
        'vf' => 'crop=floor(iw/2)*2:floor(ih/2)*2', # h264 requires width and height to be even. Crop instead of scale to avoid blurring
        'c:v' => 'h264',
        'maxrate' => '1300K',
        'bufsize' => '1300K',
        'b:v' => '1300K',
        'frames:v' => 60 * 60 * 3,
        'crf' => 18,
        'c:a' => 'aac',
        'b:a' => '192k',
        'map_metadata' => '-1',
        'frames:v' => MAX_VIDEO_FRAMES,
      }.freeze,
    }.freeze,
  }.freeze


@@ 135,7 134,7 @@ class MediaAttachment < ApplicationRecord
      convert_options: {
        output: {
          'loglevel' => 'fatal',
          :vf => 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
          :vf => 'scale=\'min(640\, iw):min(640\, ih)\':force_original_aspect_ratio=decrease',
        }.freeze,
      }.freeze,
      format: 'png',

M app/models/poll.rb => app/models/poll.rb +1 -0
@@ 28,6 28,7 @@ class Poll < ApplicationRecord

  has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all
  has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account
  has_many :local_voters, -> { group('accounts.id').merge(Account.local) }, through: :votes, class_name: 'Account', source: :account

  has_many :notifications, as: :activity, dependent: :destroy


A app/models/software_update.rb => app/models/software_update.rb +40 -0
@@ 0,0 1,40 @@
# frozen_string_literal: true

# == Schema Information
#
# Table name: software_updates
#
#  id            :bigint(8)        not null, primary key
#  version       :string           not null
#  urgent        :boolean          default(FALSE), not null
#  type          :integer          default("patch"), not null
#  release_notes :string           default(""), not null
#  created_at    :datetime         not null
#  updated_at    :datetime         not null
#

class SoftwareUpdate < ApplicationRecord
  self.inheritance_column = nil

  enum type: { patch: 0, minor: 1, major: 2 }, _suffix: :type

  def gem_version
    Gem::Version.new(version)
  end

  class << self
    def check_enabled?
      ENV['UPDATE_CHECK_URL'] != ''
    end

    def pending_to_a
      return [] unless check_enabled?

      all.to_a.filter { |update| update.gem_version > Mastodon::Version.gem_version }
    end

    def urgent_pending?
      pending_to_a.any?(&:urgent?)
    end
  end
end

M app/models/status.rb => app/models/status.rb +12 -31
@@ 39,6 39,7 @@ class Status < ApplicationRecord
  include StatusSnapshotConcern
  include RateLimitable
  include StatusSafeReblogInsert
  include StatusSearchConcern

  rate_limit by: :account, family: :statuses



@@ 49,6 50,7 @@ class Status < ApplicationRecord
  attr_accessor :override_timestamps

  update_index('statuses', :proper)
  update_index('public_statuses', :proper)

  enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 }, _suffix: :visibility



@@ 72,6 74,12 @@ class Status < ApplicationRecord
  has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
  has_many :media_attachments, dependent: :nullify

  # Those associations are used for the private search index
  has_many :local_mentioned, -> { merge(Account.local) }, through: :active_mentions, source: :account
  has_many :local_favorited, -> { merge(Account.local) }, through: :favourites, source: :account
  has_many :local_reblogged, -> { merge(Account.local) }, through: :reblogs, source: :account
  has_many :local_bookmarked, -> { merge(Account.local) }, through: :bookmarks, source: :account

  has_and_belongs_to_many :tags
  has_and_belongs_to_many :preview_cards



@@ 172,37 180,6 @@ class Status < ApplicationRecord
    "v3:#{super}"
  end

  def searchable_by(preloaded = nil)
    ids = []

    ids << account_id if local?

    if preloaded.nil?
      ids += mentions.joins(:account).merge(Account.local).active.pluck(:account_id)
      ids += favourites.joins(:account).merge(Account.local).pluck(:account_id)
      ids += reblogs.joins(:account).merge(Account.local).pluck(:account_id)
      ids += bookmarks.joins(:account).merge(Account.local).pluck(:account_id)
      ids += poll.votes.joins(:account).merge(Account.local).pluck(:account_id) if poll.present?
    else
      ids += preloaded.mentions[id] || []
      ids += preloaded.favourites[id] || []
      ids += preloaded.reblogs[id] || []
      ids += preloaded.bookmarks[id] || []
      ids += preloaded.votes[id] || []
    end

    ids.uniq
  end

  def searchable_text
    [
      spoiler_text,
      FormattingHelper.extract_status_plain_text(self),
      preloadable_poll ? preloadable_poll.options.join("\n\n") : nil,
      ordered_media_attachments.map(&:description).join("\n\n"),
    ].compact.join("\n\n")
  end

  def to_log_human_identifier
    account.acct
  end


@@ 277,6 254,10 @@ class Status < ApplicationRecord
    preview_cards.any?
  end

  def with_poll?
    preloadable_poll.present?
  end

  def non_sensitive_with_media?
    !sensitive? && with_media?
  end

M app/models/user_settings.rb => app/models/user_settings.rb +1 -0
@@ 52,6 52,7 @@ class UserSettings
    setting :link_trends, default: false
    setting :status_trends, default: false
    setting :appeal, default: true
    setting :software_updates, default: 'critical', in: %w(none critical patch all)
  end

  namespace :interactions do

A app/policies/software_update_policy.rb => app/policies/software_update_policy.rb +7 -0
@@ 0,0 1,7 @@
# frozen_string_literal: true

class SoftwareUpdatePolicy < ApplicationPolicy
  def index?
    role.can?(:view_devops)
  end
end

M app/presenters/initial_state_presenter.rb => app/presenters/initial_state_presenter.rb +5 -1
@@ 3,9 3,13 @@
class InitialStatePresenter < ActiveModelSerializers::Model
  attributes :settings, :push_subscription, :token,
             :current_account, :admin, :owner, :text, :visibility,
             :disabled_account, :moved_to_account
             :disabled_account, :moved_to_account, :critical_updates_pending

  def role
    current_account&.user_role
  end

  def critical_updates_pending
    role&.can?(:view_devops) && SoftwareUpdate.urgent_pending?
  end
end

M app/serializers/activitypub/actor_serializer.rb => app/serializers/activitypub/actor_serializer.rb +6 -2
@@ 8,13 8,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer

  context_extensions :manually_approves_followers, :featured, :also_known_as,
                     :moved_to, :property_value, :discoverable, :olm, :suspended,
                     :memorial
                     :memorial, :indexable

  attributes :id, :type, :following, :followers,
             :inbox, :outbox, :featured, :featured_tags,
             :preferred_username, :name, :summary,
             :url, :manually_approves_followers,
             :discoverable, :published, :memorial
             :discoverable, :indexable, :published, :memorial

  has_one :public_key, serializer: ActivityPub::PublicKeySerializer



@@ 99,6 99,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
    object.suspended? ? false : (object.discoverable || false)
  end

  def indexable
    object.suspended? ? false : (object.indexable || false)
  end

  def name
    object.suspended? ? object.username : (object.display_name.presence || object.username)
  end

M app/serializers/initial_state_serializer.rb => app/serializers/initial_state_serializer.rb +2 -0
@@ 8,6 8,8 @@ class InitialStateSerializer < ActiveModel::Serializer
             :max_toot_chars, :poll_limits,
             :languages

  attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }

  has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
  has_one :role, serializer: REST::RoleSerializer


M app/serializers/webfinger_serializer.rb => app/serializers/webfinger_serializer.rb +25 -12
@@ 18,18 18,31 @@ class WebfingerSerializer < ActiveModel::Serializer
  end

  def links
    if object.instance_actor?
      [
        { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: about_more_url(instance_actor: true) },
        { rel: 'self', type: 'application/activity+json', href: instance_actor_url },
        { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
      ]
    else
      [
        { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) },
        { rel: 'self', type: 'application/activity+json', href: account_url(object) },
        { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
      ]
    [
      { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profile_page_href },
      { rel: 'self', type: 'application/activity+json', href: self_href },
      { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
    ].tap do |x|
      x << { rel: 'http://webfinger.net/rel/avatar', type: object.avatar.content_type, href: full_asset_url(object.avatar_original_url) } if show_avatar?
    end
  end

  private

  def show_avatar?
    media_present = object.avatar.present? && object.avatar.content_type.present?

    # Show avatar only if an instance shows profiles to logged out users
    allowed_by_config = ENV['DISALLOW_UNAUTHENTICATED_API_ACCESS'] != 'true' && !Rails.configuration.x.limited_federation_mode

    media_present && allowed_by_config
  end

  def profile_page_href
    object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object)
  end

  def self_href
    object.instance_actor? ? instance_actor_url : account_url(object)
  end
end

M app/services/batched_remove_status_service.rb => app/services/batched_remove_status_service.rb +4 -1
@@ 38,7 38,10 @@ class BatchedRemoveStatusService < BaseService

    # Since we skipped all callbacks, we also need to manually
    # deindex the statuses
    Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs) if Chewy.enabled?
    if Chewy.enabled?
      Chewy.strategy.current.update(StatusesIndex, statuses_and_reblogs)
      Chewy.strategy.current.update(PublicStatusesIndex, statuses_and_reblogs)
    end

    return if options[:skip_side_effects]


M app/services/concerns/payloadable.rb => app/services/concerns/payloadable.rb +3 -1
@@ 1,6 1,8 @@
# frozen_string_literal: true

module Payloadable
  include AuthorizedFetchHelper

  # @param [ActiveModelSerializers::Model] record
  # @param [ActiveModelSerializers::Serializer] serializer
  # @param [Hash] options


@@ 23,6 25,6 @@ module Payloadable
  end

  def signing_enabled?
    ENV['AUTHORIZED_FETCH'] != 'true' && !Rails.configuration.x.limited_federation_mode
    !authorized_fetch_mode?
  end
end

M app/services/search_service.rb => app/services/search_service.rb +18 -32
@@ 1,8 1,10 @@
# frozen_string_literal: true

class SearchService < BaseService
  QUOTE_EQUIVALENT_CHARACTERS = /[“”„«»「」『』《》]/

  def call(query, account, limit, options = {})
    @query     = query&.strip
    @query     = query&.strip&.gsub(QUOTE_EQUIVALENT_CHARACTERS, '"')
    @account   = account
    @options   = options
    @limit     = limit.to_i


@@ 17,7 19,7 @@ class SearchService < BaseService
        results.merge!(url_resource_results) unless url_resource.nil? || @offset.positive? || (@options[:type].present? && url_resource_symbol != @options[:type].to_sym)
      elsif @query.present?
        results[:accounts] = perform_accounts_search! if account_searchable?
        results[:statuses] = perform_statuses_search! if full_text_searchable?
        results[:statuses] = perform_statuses_search! if status_searchable?
        results[:hashtags] = perform_hashtags_search! if hashtag_searchable?
      end
    end


@@ 39,25 41,15 @@ class SearchService < BaseService
  end

  def perform_statuses_search!
    definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id }))

    definition = definition.filter(term: { account_id: @options[:account_id] }) if @options[:account_id].present?

    if @options[:min_id].present? || @options[:max_id].present?
      range      = {}
      range[:gt] = @options[:min_id].to_i if @options[:min_id].present?
      range[:lt] = @options[:max_id].to_i if @options[:max_id].present?
      definition = definition.filter(range: { id: range })
    end

    results             = definition.limit(@limit).offset(@offset).objects.compact
    account_ids         = results.map(&:account_id)
    account_domains     = results.map(&:account_domain)
    preloaded_relations = @account.relations_map(account_ids, account_domains)

    results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
  rescue Faraday::ConnectionFailed, Parslet::ParseFailed
    []
    StatusesSearchService.new.call(
      @query,
      @account,
      limit: @limit,
      offset: @offset,
      account_id: @options[:account_id],
      min_id: @options[:min_id],
      max_id: @options[:max_id]
    )
  end

  def perform_hashtags_search!


@@ 89,18 81,16 @@ class SearchService < BaseService
    url_resource.class.name.downcase.pluralize.to_sym
  end

  def full_text_searchable?
    return false unless Chewy.enabled?

    statuses_search? && !@account.nil? && !(@query.include?('@') && !@query.include?(' '))
  def status_searchable?
    Chewy.enabled? && status_search? && @account.present?
  end

  def account_searchable?
    account_search? && !(@query.include?('@') && @query.include?(' '))
    account_search?
  end

  def hashtag_searchable?
    hashtag_search? && !@query.include?('@')
    hashtag_search?
  end

  def account_search?


@@ 111,11 101,7 @@ class SearchService < BaseService
    @options[:type].blank? || @options[:type] == 'hashtags'
  end

  def statuses_search?
  def status_search?
    @options[:type].blank? || @options[:type] == 'statuses'
  end

  def parsed_query
    SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
  end
end

A app/services/software_update_check_service.rb => app/services/software_update_check_service.rb +82 -0
@@ 0,0 1,82 @@
# frozen_string_literal: true

class SoftwareUpdateCheckService < BaseService
  def call
    clean_outdated_updates!
    return unless SoftwareUpdate.check_enabled?

    process_update_notices!(fetch_update_notices)
  end

  private

  def clean_outdated_updates!
    SoftwareUpdate.find_each do |software_update|
      software_update.delete if Mastodon::Version.gem_version >= software_update.gem_version
    rescue ArgumentError
      software_update.delete
    end
  end

  def fetch_update_notices
    Request.new(:get, "#{api_url}?version=#{version}").add_headers('Accept' => 'application/json', 'User-Agent' => 'Mastodon update checker').perform do |res|
      return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200
    end
  rescue HTTP::Error, OpenSSL::SSL::SSLError, Oj::ParseError
    nil
  end

  def api_url
    ENV.fetch('UPDATE_CHECK_URL', 'https://api.joinmastodon.org/update-check')
  end

  def version
    @version ||= Mastodon::Version.to_s.split('+')[0]
  end

  def process_update_notices!(update_notices)
    return if update_notices.blank? || update_notices['updatesAvailable'].blank?

    # Clear notices that are not listed by the update server anymore
    SoftwareUpdate.where.not(version: update_notices['updatesAvailable'].pluck('version')).delete_all

    # Check if any of the notices is new, and issue notifications
    known_versions = SoftwareUpdate.where(version: update_notices['updatesAvailable'].pluck('version')).pluck(:version)
    new_update_notices = update_notices['updatesAvailable'].filter { |notice| known_versions.exclude?(notice['version']) }
    return if new_update_notices.blank?

    new_updates = new_update_notices.map do |notice|
      SoftwareUpdate.create!(version: notice['version'], urgent: notice['urgent'], type: notice['type'], release_notes: notice['releaseNotes'])
    end

    notify_devops!(new_updates)
  end

  def should_notify_user?(user, urgent_version, patch_version)
    case user.settings['notification_emails.software_updates']
    when 'none'
      false
    when 'critical'
      urgent_version
    when 'patch'
      urgent_version || patch_version
    when 'all'
      true
    end
  end

  def notify_devops!(new_updates)
    has_new_urgent_version = new_updates.any?(&:urgent?)
    has_new_patch_version  = new_updates.any?(&:patch_type?)

    User.those_who_can(:view_devops).includes(:account).find_each do |user|
      next unless should_notify_user?(user, has_new_urgent_version, has_new_patch_version)

      if has_new_urgent_version
        AdminMailer.with(recipient: user.account).new_critical_software_updates.deliver_later
      else
        AdminMailer.with(recipient: user.account).new_software_updates.deliver_later
      end
    end
  end
end

A app/services/statuses_search_service.rb => app/services/statuses_search_service.rb +64 -0
@@ 0,0 1,64 @@
# frozen_string_literal: true

class StatusesSearchService < BaseService
  def call(query, account = nil, options = {})
    @query   = query&.strip
    @account = account
    @options = options
    @limit   = options[:limit].to_i
    @offset  = options[:offset].to_i

    status_search_results
  end

  private

  def status_search_results
    definition = parsed_query.apply(
      Chewy::Search::Request.new(StatusesIndex, PublicStatusesIndex).filter(
        bool: {
          should: [
            publicly_searchable,
            non_publicly_searchable,
          ],

          minimum_should_match: 1,
        }
      )
    )

    results             = definition.collapse(field: :id).order(id: { order: :desc }).limit(@limit).offset(@offset).objects.compact
    account_ids         = results.map(&:account_id)
    account_domains     = results.map(&:account_domain)
    preloaded_relations = @account.relations_map(account_ids, account_domains)

    results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
  rescue Faraday::ConnectionFailed, Parslet::ParseFailed
    []
  end

  def publicly_searchable
    {
      term: { _index: PublicStatusesIndex.index_name },
    }
  end

  def non_publicly_searchable
    {
      bool: {
        must: [
          {
            term: { _index: StatusesIndex.index_name },
          },
          {
            term: { searchable_by: @account.id },
          },
        ],
      },
    }
  end

  def parsed_query
    SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query), current_account: @account)
  end
end

M app/views/admin/settings/discovery/show.html.haml => app/views/admin/settings/discovery/show.html.haml +5 -0
@@ 42,6 42,11 @@
  .fields-group
    = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, recommended: :recommended

  %h4= t('admin.settings.security.federation_authentication')

  .fields-group
    = f.input :authorized_fetch, as: :boolean, wrapper: :with_label, label: t('admin.settings.security.authorized_fetch'), warning_hint: authorized_fetch_overridden? ? t('admin.settings.security.authorized_fetch_overridden_hint') : nil, hint: t('admin.settings.security.authorized_fetch_hint'), disabled: authorized_fetch_overridden?, recommended: authorized_fetch_overridden? ? :overridden : nil

  %h4= t('admin.settings.discovery.follow_recommendations')

  .fields-group

A app/views/admin/software_updates/index.html.haml => app/views/admin/software_updates/index.html.haml +29 -0
@@ 0,0 1,29 @@
- content_for :page_title do
  = t('admin.software_updates.title')

.simple_form
  %p.lead
    = t('admin.software_updates.description')
    = link_to t('admin.software_updates.documentation_link'), 'https://docs.joinmastodon.org/admin/upgrading/#automated_checks', target: '_new'

%hr.spacer

- unless @software_updates.empty?
  .table-wrapper
    %table.table
      %thead
        %tr
          %th= t('admin.software_updates.version')
          %th= t('admin.software_updates.type')
          %th
          %th
      %tbody
        - @software_updates.each do |update|
          %tr
            %td= update.version
            %td= t("admin.software_updates.types.#{update.type}")
            - if update.urgent?
              %td.critical= t("admin.software_updates.critical_update")
            - else
              %td
            %td= table_link_to 'link', t('admin.software_updates.release_notes'), update.release_notes

A app/views/admin_mailer/new_critical_software_updates.text.erb => app/views/admin_mailer/new_critical_software_updates.text.erb +5 -0
@@ 0,0 1,5 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>

<%= raw t('admin_mailer.new_critical_software_updates.body') %>

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

A app/views/admin_mailer/new_software_updates.text.erb => app/views/admin_mailer/new_software_updates.text.erb +5 -0
@@ 0,0 1,5 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>

<%= raw t('admin_mailer.new_software_updates.body') %>

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

M app/views/settings/preferences/notifications/show.html.haml => app/views/settings/preferences/notifications/show.html.haml +5 -1
@@ 22,7 22,7 @@
    .fields-group
      = ff.input :always_send_emails, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_always_send_emails'), hint: I18n.t('simple_form.hints.defaults.setting_always_send_emails')

    - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies)
    - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies) || (SoftwareUpdate.check_enabled? && current_user.can?(:view_devops))
      %h4= t 'notifications.administration_emails'

      .fields-group


@@ 33,6 33,10 @@
        = ff.input :'notification_emails.link_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_link') if current_user.can?(:manage_taxonomies)
        = ff.input :'notification_emails.status_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_status') if current_user.can?(:manage_taxonomies)

      - if SoftwareUpdate.check_enabled? && current_user.can?(:view_devops)
        .fields-group
          = ff.input :'notification_emails.software_updates', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.software_updates.label'), collection: %w(none critical patch all), label_method: ->(setting) { I18n.t("simple_form.labels.notification_emails.software_updates.#{setting}") }, include_blank: false, hint: false

  %h4= t 'notifications.other_settings'

  .fields-group

M app/views/settings/privacy/show.html.haml => app/views/settings/privacy/show.html.haml +3 -0
@@ 24,6 24,9 @@

  %p.lead= t('privacy.search_hint_html')

  .fields-group
    = f.input :indexable, as: :boolean, wrapper: :with_label

  = f.simple_fields_for :settings, current_user.settings do |ff|
    .fields-group
      = ff.input :indexable, wrapper: :with_label

A app/workers/add_to_public_statuses_index_worker.rb => app/workers/add_to_public_statuses_index_worker.rb +22 -0
@@ 0,0 1,22 @@
# frozen_string_literal: true

class AddToPublicStatusesIndexWorker
  include Sidekiq::Worker
  include DatabaseHelper

  sidekiq_options queue: 'pull'

  def perform(account_id)
    with_primary do
      @account = Account.find(account_id)
    end

    return unless @account.indexable?

    with_read_replica do
      @account.add_to_public_statuses_index!
    end
  rescue ActiveRecord::RecordNotFound
    true
  end
end

A app/workers/remove_from_public_statuses_index_worker.rb => app/workers/remove_from_public_statuses_index_worker.rb +15 -0
@@ 0,0 1,15 @@
# frozen_string_literal: true

class RemoveFromPublicStatusesIndexWorker
  include Sidekiq::Worker

  def perform(account_id)
    account = Account.find(account_id)

    return if account.indexable?

    account.remove_from_public_statuses_index!
  rescue ActiveRecord::RecordNotFound
    true
  end
end

M app/workers/scheduler/indexing_scheduler.rb => app/workers/scheduler/indexing_scheduler.rb +3 -1
@@ 3,6 3,7 @@
class Scheduler::IndexingScheduler
  include Sidekiq::Worker
  include Redisable
  include DatabaseHelper

  sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i



@@ 16,6 17,7 @@ class Scheduler::IndexingScheduler
      with_redis do |redis|
        redis.sscan_each("chewy:queue:#{type.name}", count: SCAN_BATCH_SIZE).each_slice(IMPORT_BATCH_SIZE) do |ids|
          type.import!(ids)

          redis.srem("chewy:queue:#{type.name}", ids)
        end
      end


@@ 23,6 25,6 @@ class Scheduler::IndexingScheduler
  end

  def indexes
    [AccountsIndex, TagsIndex, StatusesIndex]
    [AccountsIndex, TagsIndex, PublicStatusesIndex, StatusesIndex]
  end
end

A app/workers/scheduler/software_update_check_scheduler.rb => app/workers/scheduler/software_update_check_scheduler.rb +11 -0
@@ 0,0 1,11 @@
# frozen_string_literal: true

class Scheduler::SoftwareUpdateCheckScheduler
  include Sidekiq::Worker

  sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.hour.to_i

  def perform
    SoftwareUpdateCheckService.new.call
  end
end

M config/environments/development.rb => config/environments/development.rb +2 -0
@@ 35,6 35,8 @@ Rails.application.configure do
    config.cache_store = :null_store
  end

  config.action_controller.forgery_protection_origin_check = ENV['DISABLE_FORGERY_REQUEST_PROTECTION'].nil?

  ActiveSupport::Logger.new(STDOUT).tap do |logger|
    logger.formatter = config.log_formatter
    config.logger = ActiveSupport::TaggedLogging.new(logger)

M config/i18n-tasks.yml => config/i18n-tasks.yml +1 -1
@@ 57,7 57,7 @@ ignore_unused:
  - 'activerecord.errors.*'
  - '{devise,pagination,doorkeeper}.*'
  - '{date,datetime,time,number}.*'
  - 'simple_form.{yes,no,recommended,not_recommended,glitch_only}'
  - 'simple_form.{yes,no,recommended,not_recommended,overridden,glitch_only}'
  - 'simple_form.{placeholders,hints,labels}.*'
  - 'simple_form.{error_notification,required}.:'
  - 'errors.messages.*'

M config/initializers/simple_form.rb => config/initializers/simple_form.rb +3 -2
@@ 108,7 108,8 @@ SimpleForm.setup do |config|
      end
    end

    b.use :hint,  wrap_with: { tag: :span, class: :hint }
    b.use :warning_hint, wrap_with: { tag: :span, class: [:hint, 'warning-hint'] }
    b.use :hint, wrap_with: { tag: :span, class: :hint }
    b.use :error, wrap_with: { tag: :span, class: :error }
  end



@@ 122,8 123,8 @@ SimpleForm.setup do |config|
  config.wrappers :with_block_label, class: [:input, :with_block_label], hint_class: :field_with_hint, error_class: :field_with_errors do |b|
    b.use :html5
    b.use :label
    b.use :hint, wrap_with: { tag: :span, class: :hint }
    b.use :warning_hint, wrap_with: { tag: :span, class: [:hint, 'warning-hint'] }
    b.use :hint, wrap_with: { tag: :span, class: :hint }
    b.use :input, wrap_with: { tag: :div, class: :label_input }
    b.use :error, wrap_with: { tag: :span, class: :error }
  end

M config/locales/en.yml => config/locales/en.yml +34 -0
@@ 309,6 309,7 @@ en:
      unpublish: Unpublish
      unpublished_msg: Announcement successfully unpublished!
      updated_msg: Announcement successfully updated!
    critical_update_pending: Critical update pending
    custom_emojis:
      assign_category: Assign category
      by_domain: Domain


@@ 770,10 771,27 @@ en:
          approved: Approval required for sign up
          none: Nobody can sign up
          open: Anyone can sign up
      security:
        authorized_fetch: Require authentication from federated servers
        authorized_fetch_hint: Requiring authentication from federated servers enables stricter enforcement of both user-level and server-level blocks. However, this comes at the cost of a performance penalty, reduces the reach of your replies, and may introduce compatibility issues with some federated services. In addition, this will not prevent dedicated actors from fetching your public posts and accounts.
        authorized_fetch_overridden_hint: You are currently unable to change this setting because it is overridden by an environment variable.
        federation_authentication: Federation authentication enforcement
      title: Server settings
    site_uploads:
      delete: Delete uploaded file
      destroyed_msg: Site upload successfully deleted!
    software_updates:
      critical_update: Critical — please update quickly
      description: It is recommended to keep your Mastodon installation up to date to benefit from the latest fixes and features. Moreover, it is sometimes critical to update Mastodon in a timely manner to avoid security issues. For these reasons, Mastodon checks for updates every 30 minutes, and will notify you according to your e-mail notification preferences.
      documentation_link: Learn more
      release_notes: Release notes
      title: Available updates
      type: Type
      types:
        major: Major release
        minor: Minor release
        patch: Patch release — bugfixes and easy to apply changes
      version: Version
    statuses:
      account: Author
      application: Application


@@ 838,6 856,12 @@ en:
        message_html: You haven't defined any server rules.
      sidekiq_process_check:
        message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration
      software_version_critical_check:
        action: See available updates
        message_html: A critical Mastodon update is available, please update as quickly as possible.
      software_version_patch_check:
        action: See available updates
        message_html: A bugfix Mastodon update is available.
      upload_check_privacy_error:
        action: Check here for more information
        message_html: "<strong>Your web server is misconfigured. The privacy of your users is at risk.</strong>"


@@ 951,6 975,9 @@ en:
      body: "%{target} is appealing a moderation decision by %{action_taken_by} from %{date}, which was %{type}. They wrote:"
      next_steps: You can approve the appeal to undo the moderation decision, or ignore it.
      subject: "%{username} is appealing a moderation decision on %{instance}"
    new_critical_software_updates:
      body: New critical versions of Mastodon have been released, you may want to update as soon as possible!
      subject: Critical Mastodon updates are available for %{instance}!
    new_pending_account:
      body: The details of the new account are below. You can approve or reject this application.
      subject: New account up for review on %{instance} (%{username})


@@ 958,6 985,9 @@ en:
      body: "%{reporter} has reported %{target}"
      body_remote: Someone from %{domain} has reported %{target}
      subject: New report for %{instance} (#%{id})
    new_software_updates:
      body: New Mastodon versions have been released, you may want to update!
      subject: New Mastodon versions are available for %{instance}!
    new_trends:
      body: 'The following items need a review before they can be displayed publicly:'
      new_trending_links:


@@ 1709,6 1739,10 @@ en:
      default: "%b %d, %Y, %H:%M"
      month: "%b %Y"
      time: "%H:%M"
  translation:
    errors:
      quota_exceeded: The server-wide usage quota for the translation service has been exceeded.
      too_many_requests: There have been too many requests to the translation service recently.
  two_factor_authentication:
    add: Add
    disable: Disable 2FA

M config/locales/simple_form.en.yml => config/locales/simple_form.en.yml +9 -0
@@ 6,6 6,7 @@ en:
        discoverable: Your public posts and profile may be featured or recommended in various areas of Mastodon and your profile may be suggested to other users.
        display_name: Your full name or your fun name.
        fields: Your homepage, pronouns, age, anything you want.
        indexable: Your public posts may appear in search results on Mastodon. People who have interacted with your posts may be able to search them regardless.
        note: 'You can @mention other people or #hashtags.'
        show_collections: People will be able to browse through your follows and followers. People that you follow will see that you follow them regardless.
        unlocked: People will be able to follow you without requesting approval. Uncheck if you want to review follow requests and chose whether to accept or reject new followers.


@@ 143,6 144,7 @@ en:
        fields:
          name: Label
          value: Content
        indexable: Include public posts in search results
        show_collections: Show follows and followers on profile
        unlocked: Automatically accept new followers
      account_alias:


@@ 289,6 291,12 @@ en:
        pending_account: New account needs review
        reblog: Someone boosted your post
        report: New report is submitted
        software_updates:
          all: Notify on all updates
          critical: Notify on critical updates only
          label: A new Mastodon version is available
          none: Never notify of updates (not recommended)
          patch: Notify on bugfix updates
        trending_tag: New trend requires review
      rule:
        text: Rule


@@ 315,6 323,7 @@ en:
        url: Endpoint URL
    'no': 'No'
    not_recommended: Not recommended
    overridden: Overridden
    recommended: Recommended
    required:
      mark: "*"

M config/navigation.rb => config/navigation.rb +3 -0
@@ 3,6 3,9 @@
SimpleNavigation::Configuration.run do |navigation|
  navigation.items do |n|
    n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_path

    n.item :software_updates, safe_join([fa_icon('exclamation-circle fw'), t('admin.critical_update_pending')]), admin_software_updates_path, if: -> { ENV['UPDATE_CHECK_URL'] != '' && current_user.can?(:view_devops) && SoftwareUpdate.urgent_pending? }, html: { class: 'warning' }

    n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_path, if: -> { current_user.functional? }, highlights_on: %r{/settings/profile|/settings/featured_tags|/settings/verification|/settings/privacy}

    n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_path, if: -> { current_user.functional? } do |s|

M config/routes/admin.rb => config/routes/admin.rb +2 -0
@@ 202,4 202,6 @@ namespace :admin do
      end
    end
  end

  resources :software_updates, only: [:index]
end

M config/sidekiq.yml => config/sidekiq.yml +4 -0
@@ 58,3 58,7 @@
      interval: 1 minute
      class: Scheduler::SuspendedUserCleanupScheduler
      queue: scheduler
    software_update_check_scheduler:
      interval: 30 minutes
      class: Scheduler::SoftwareUpdateCheckScheduler
      queue: scheduler

M config/webpacker.yml => config/webpacker.yml +1 -1
@@ 59,7 59,7 @@ development:
  # Reference: https://webpack.js.org/configuration/dev-server/
  dev_server:
    https: false
    host: localhost
    host: 0.0.0.0
    port: 3035
    public: localhost:3035
    hmr: false

A db/migrate/20230822081029_create_software_updates.rb => db/migrate/20230822081029_create_software_updates.rb +16 -0
@@ 0,0 1,16 @@
# frozen_string_literal: true

class CreateSoftwareUpdates < ActiveRecord::Migration[7.0]
  def change
    create_table :software_updates do |t|
      t.string :version, null: false
      t.boolean :urgent, default: false, null: false
      t.integer :type, default: 0, null: false
      t.string :release_notes, default: '', null: false

      t.timestamps
    end

    add_index :software_updates, :version, unique: true
  end
end

M db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb => db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb +13 -1
@@ 15,10 15,22 @@ class AddUniqueIndexOnPreviewCardsStatuses < ActiveRecord::Migration[6.1]

  private

  def supports_concurrent_reindex?
    @supports_concurrent_reindex ||= begin
      version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
      version >= 12_000
    end
  end

  def deduplicate_and_reindex!
    deduplicate_preview_cards!

    safety_assured { execute 'REINDEX INDEX CONCURRENTLY preview_cards_statuses_pkey' }
    if supports_concurrent_reindex?
      safety_assured { execute 'REINDEX INDEX CONCURRENTLY preview_cards_statuses_pkey' }
    else
      remove_index :preview_cards_statuses, name: :preview_cards_statuses_pkey
      add_index :preview_cards_statuses, [:status_id, :preview_card_id], name: :preview_cards_statuses_pkey, algorithm: :concurrently, unique: true
    end
  rescue ActiveRecord::RecordNotUnique
    retry
  end

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

ActiveRecord::Schema[7.0].define(version: 2023_08_18_142253) do
ActiveRecord::Schema[7.0].define(version: 2023_08_22_081029) do
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"



@@ 903,6 903,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_18_142253) do
    t.index ["var"], name: "index_site_uploads_on_var", unique: true
  end

  create_table "software_updates", force: :cascade do |t|
    t.string "version", null: false
    t.boolean "urgent", default: false, null: false
    t.integer "type", default: 0, null: false
    t.string "release_notes", default: "", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["version"], name: "index_software_updates_on_version", unique: true
  end

  create_table "status_edits", force: :cascade do |t|
    t.bigint "status_id", null: false
    t.bigint "account_id"

M dist/nginx.conf => dist/nginx.conf +5 -1
@@ 36,7 36,11 @@ server {
  server_name example.com;

  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;

  # You can use https://ssl-config.mozilla.org/ to generate your cipher set.
  # We recommend their "Intermediate" level.
  ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;

  ssl_prefer_server_ciphers on;
  ssl_session_cache shared:SSL:10m;
  ssl_session_tickets off;

M lib/mastodon/cli/search.rb => lib/mastodon/cli/search.rb +1 -0
@@ 10,6 10,7 @@ module Mastodon::CLI
      InstancesIndex,
      AccountsIndex,
      TagsIndex,
      PublicStatusesIndex,
      StatusesIndex,
    ].freeze


M lib/mastodon/version.rb => lib/mastodon/version.rb +16 -5
@@ 16,12 16,16 @@ module Mastodon
      0
    end

    def flags
      ENV['MASTODON_VERSION_FLAGS'].presence || '-beta2'
    def default_prerelease
      'beta2'
    end

    def suffix
      "+glitch#{ENV.fetch('MASTODON_VERSION_SUFFIX', '')}"
    def prerelease
      ENV['MASTODON_VERSION_PRERELEASE'].presence || default_prerelease
    end

    def build_metadata
      ['glitch', ENV.fetch('MASTODON_VERSION_METADATA', nil)].compact.join('.')
    end

    def to_a


@@ 29,7 33,14 @@ module Mastodon
    end

    def to_s
      [to_a.join('.'), flags, suffix].join
      components = [to_a.join('.')]
      components << "-#{prerelease}" if prerelease.present?
      components << "+#{build_metadata}" if build_metadata.present?
      components.join
    end

    def gem_version
      @gem_version ||= Gem::Version.new(to_s.split('+')[0])
    end

    def repository

M lib/paperclip/transcoder.rb => lib/paperclip/transcoder.rb +8 -2
@@ 4,6 4,9 @@ module Paperclip
  # This transcoder is only to be used for the MediaAttachment model
  # to check when uploaded videos are actually gifv's
  class Transcoder < Paperclip::Processor
    # This is the H.264 "High" value taken from https://www.dr-lex.be/info-stuff/videocalc.html
    BITS_PER_PIXEL = 0.11

    def initialize(file, options = {}, attachment = nil)
      super



@@ 38,8 41,11 @@ module Paperclip
        @output_options['vframes'] = 1
      when 'mp4'
        unless eligible_to_passthrough?(metadata)
          @output_options['acodec'] = 'aac'
          @output_options['strict'] = 'experimental'
          bitrate = (metadata.width * metadata.height * 30 * BITS_PER_PIXEL) / 1_000

          @output_options['b:v']     = "#{bitrate}k"
          @output_options['maxrate'] = "#{bitrate + 192}k"
          @output_options['bufsize'] = "#{bitrate * 5}k"

          if high_vfr?(metadata)
            @output_options['vsync'] = 'vfr'

M lib/tasks/mastodon.rake => lib/tasks/mastodon.rake +4 -0
@@ 425,6 425,10 @@ namespace :mastodon do
      end

      prompt.say "\n"

      env['UPDATE_CHECK_URL'] = '' unless prompt.yes?('Do you want Mastodon to periodically check for important updates and notify you? (Recommended)', default: true)

      prompt.say "\n"
      prompt.say 'This configuration will be written to .env.production'

      if prompt.yes?('Save configuration?')

M package.json => package.json +2 -2
@@ 85,6 85,7 @@
    "immutable": "^4.3.0",
    "imports-loader": "^1.2.0",
    "intl-messageformat": "^10.3.5",
    "ioredis": "^5.3.2",
    "js-yaml": "^4.1.0",
    "jsdom": "^22.1.0",
    "lodash": "^4.17.21",


@@ 121,7 122,6 @@
    "react-swipeable-views": "^0.14.0",
    "react-textarea-autosize": "^8.4.1",
    "react-toggle": "^4.1.3",
    "redis": "^4.6.5",
    "redux": "^4.2.1",
    "redux-immutable": "^4.0.0",
    "redux-thunk": "^2.4.2",


@@ 155,7 155,7 @@
  },
  "devDependencies": {
    "@formatjs/cli": "^6.1.1",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/jest-dom": "^6.0.0",
    "@testing-library/react": "^14.0.0",
    "@types/babel__core": "^7.20.1",
    "@types/emoji-mart": "^3.0.9",

A spec/chewy/public_statuses_index_spec.rb => spec/chewy/public_statuses_index_spec.rb +31 -0
@@ 0,0 1,31 @@
# frozen_string_literal: true

require 'rails_helper'

describe PublicStatusesIndex do
  describe 'Searching the index' do
    before do
      mock_elasticsearch_response(described_class, raw_response)
    end

    it 'returns results from a query' do
      results = described_class.query(match: { name: 'status' })

      expect(results).to eq []
    end
  end

  def raw_response
    {
      took: 3,
      hits: {
        hits: [
          {
            _id: '0',
            _score: 1.6375021,
          },
        ],
      },
    }
  end
end

M spec/controllers/api/v1/timelines/tag_controller_spec.rb => spec/controllers/api/v1/timelines/tag_controller_spec.rb +51 -17
@@ 5,36 5,70 @@ require 'rails_helper'
describe Api::V1::Timelines::TagController do
  render_views

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

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

  context 'with a user context' do
    let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) }
  describe 'GET #show' do
    subject do
      get :show, params: { id: 'test' }
    end

    before do
      PostStatusService.new.call(user.account, text: 'It is a #test')
    end

    describe 'GET #show' do
    context 'when the instance allows public preview' do
      before do
        PostStatusService.new.call(user.account, text: 'It is a #test')
        Setting.timeline_preview = true
      end

      context 'when the user is not authenticated' do
        let(:token) { nil }

        it 'returns http success', :aggregate_failures do
          subject

          expect(response).to have_http_status(200)
          expect(response.headers['Link'].links.size).to eq(2)
        end
      end

      it 'returns http success' do
        get :show, params: { id: 'test' }
        expect(response).to have_http_status(200)
        expect(response.headers['Link'].links.size).to eq(2)
      context 'when the user is authenticated' do
        it 'returns http success', :aggregate_failures do
          subject

          expect(response).to have_http_status(200)
          expect(response.headers['Link'].links.size).to eq(2)
        end
      end
    end
  end

  context 'without a user context' do
    let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil) }
    context 'when the instance does not allow public preview' do
      before do
        Form::AdminSettings.new(timeline_preview: false).save
      end

      context 'when the user is not authenticated' do
        let(:token) { nil }

        it 'returns http unauthorized' do
          subject

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

      context 'when the user is authenticated' do
        it 'returns http success', :aggregate_failures do
          subject

    describe 'GET #show' do
      it 'returns http success' do
        get :show, params: { id: 'test' }
        expect(response).to have_http_status(200)
        expect(response.headers['Link']).to be_nil
          expect(response).to have_http_status(200)
          expect(response.headers['Link'].links.size).to eq(2)
        end
      end
    end
  end

M spec/controllers/well_known/webfinger_controller_spec.rb => spec/controllers/well_known/webfinger_controller_spec.rb +64 -0
@@ 3,6 3,8 @@
require 'rails_helper'

describe WellKnown::WebfingerController do
  include RoutingHelper

  render_views

  describe 'GET #show' do


@@ 167,5 169,67 @@ describe WellKnown::WebfingerController do
        expect(response).to have_http_status(400)
      end
    end

    context 'when an account has an avatar' do
      let(:alice) { Fabricate(:account, username: 'alice', avatar: attachment_fixture('attachment.jpg')) }
      let(:resource) { alice.to_webfinger_s }

      it 'returns avatar in response' do
        perform_show!

        avatar_link = get_avatar_link(body_as_json)
        expect(avatar_link).to_not be_nil
        expect(avatar_link[:type]).to eq alice.avatar.content_type
        expect(avatar_link[:href]).to eq full_asset_url(alice.avatar)
      end

      context 'with limited federation mode' do
        before do
          allow(Rails.configuration.x).to receive(:limited_federation_mode).and_return(true)
        end

        it 'does not return avatar in response' do
          perform_show!

          avatar_link = get_avatar_link(body_as_json)
          expect(avatar_link).to be_nil
        end
      end

      context 'when enabling DISALLOW_UNAUTHENTICATED_API_ACCESS' do
        around do |example|
          ClimateControl.modify DISALLOW_UNAUTHENTICATED_API_ACCESS: 'true' do
            example.run
          end
        end

        it 'does not return avatar in response' do
          perform_show!

          avatar_link = get_avatar_link(body_as_json)
          expect(avatar_link).to be_nil
        end
      end
    end

    context 'when an account does not have an avatar' do
      let(:alice) { Fabricate(:account, username: 'alice', avatar: nil) }
      let(:resource) { alice.to_webfinger_s }

      before do
        perform_show!
      end

      it 'does not return avatar in response' do
        avatar_link = get_avatar_link(body_as_json)
        expect(avatar_link).to be_nil
      end
    end
  end

  private

  def get_avatar_link(json)
    json[:links].find { |link| link[:rel] == 'http://webfinger.net/rel/avatar' }
  end
end

A spec/fabricators/software_update_fabricator.rb => spec/fabricators/software_update_fabricator.rb +7 -0
@@ 0,0 1,7 @@
# frozen_string_literal: true

Fabricator(:software_update) do
  version '99.99.99'
  urgent false
  type 'patch'
end

A spec/features/admin/software_updates_spec.rb => spec/features/admin/software_updates_spec.rb +23 -0
@@ 0,0 1,23 @@
# frozen_string_literal: true

require 'rails_helper'

describe 'finding software updates through the admin interface' do
  before do
    Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: true, release_notes: 'https://github.com/mastodon/mastodon/releases/v99')

    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Owner')), scope: :user
  end

  it 'shows a link to the software updates page, which links to release notes' do
    visit settings_profile_path
    click_on I18n.t('admin.critical_update_pending')

    expect(page).to have_title(I18n.t('admin.software_updates.title'))

    expect(page).to have_content('99.99.99')

    click_on I18n.t('admin.software_updates.release_notes')
    expect(page).to have_current_path('https://github.com/mastodon/mastodon/releases/v99', url: true)
  end
end

M spec/lib/admin/system_check/elasticsearch_check_spec.rb => spec/lib/admin/system_check/elasticsearch_check_spec.rb +2 -0
@@ 17,6 17,7 @@ describe Admin::SystemCheck::ElasticsearchCheck do
        allow(Chewy.client.indices).to receive_messages(get_mapping: {
          AccountsIndex.index_name => AccountsIndex.mappings_hash.deep_stringify_keys,
          StatusesIndex.index_name => StatusesIndex.mappings_hash.deep_stringify_keys,
          PublicStatusesIndex.index_name => PublicStatusesIndex.mappings_hash.deep_stringify_keys,
          InstancesIndex.index_name => InstancesIndex.mappings_hash.deep_stringify_keys,
          TagsIndex.index_name => TagsIndex.mappings_hash.deep_stringify_keys,
        }, get_settings: {


@@ 90,6 91,7 @@ describe Admin::SystemCheck::ElasticsearchCheck do
      allow(Chewy.client.indices).to receive(:get_mapping).and_return({
        AccountsIndex.index_name => AccountsIndex.mappings_hash.deep_stringify_keys,
        StatusesIndex.index_name => StatusesIndex.mappings_hash.deep_stringify_keys,
        PublicStatusesIndex.index_name => PublicStatusesIndex.mappings_hash.deep_stringify_keys,
        InstancesIndex.index_name => InstancesIndex.mappings_hash.deep_stringify_keys,
        TagsIndex.index_name => TagsIndex.mappings_hash.deep_stringify_keys,
      })

A spec/lib/admin/system_check/software_version_check_spec.rb => spec/lib/admin/system_check/software_version_check_spec.rb +133 -0
@@ 0,0 1,133 @@
# frozen_string_literal: true

require 'rails_helper'

describe Admin::SystemCheck::SoftwareVersionCheck do
  include RoutingHelper

  subject(:check) { described_class.new(user) }

  let(:user) { Fabricate(:user) }

  describe 'skip?' do
    context 'when user cannot view devops' do
      before { allow(user).to receive(:can?).with(:view_devops).and_return(false) }

      it 'returns true' do
        expect(check.skip?).to be true
      end
    end

    context 'when user can view devops' do
      before { allow(user).to receive(:can?).with(:view_devops).and_return(true) }

      it 'returns false' do
        expect(check.skip?).to be false
      end

      context 'when checks are disabled' do
        around do |example|
          ClimateControl.modify UPDATE_CHECK_URL: '' do
            example.run
          end
        end

        it 'returns true' do
          expect(check.skip?).to be true
        end
      end
    end
  end

  describe 'pass?' do
    context 'when there is no known update' do
      it 'returns true' do
        expect(check.pass?).to be true
      end
    end

    context 'when there is a non-urgent major release' do
      before do
        Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: false)
      end

      it 'returns true' do
        expect(check.pass?).to be true
      end
    end

    context 'when there is an urgent major release' do
      before do
        Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: true)
      end

      it 'returns false' do
        expect(check.pass?).to be false
      end
    end

    context 'when there is an urgent minor release' do
      before do
        Fabricate(:software_update, version: '99.99.99', type: 'minor', urgent: true)
      end

      it 'returns false' do
        expect(check.pass?).to be false
      end
    end

    context 'when there is an urgent patch release' do
      before do
        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true)
      end

      it 'returns false' do
        expect(check.pass?).to be false
      end
    end

    context 'when there is a non-urgent patch release' do
      before do
        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false)
      end

      it 'returns false' do
        expect(check.pass?).to be false
      end
    end
  end

  describe 'message' do
    context 'when there is a non-urgent patch release pending' do
      before do
        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false)
      end

      it 'sends class name symbol to message instance' do
        allow(Admin::SystemCheck::Message).to receive(:new)
          .with(:software_version_patch_check, anything, anything)

        check.message

        expect(Admin::SystemCheck::Message).to have_received(:new)
          .with(:software_version_patch_check, nil, admin_software_updates_path)
      end
    end

    context 'when there is an urgent patch release pending' do
      before do
        Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true)
      end

      it 'sends class name symbol to message instance' do
        allow(Admin::SystemCheck::Message).to receive(:new)
          .with(:software_version_critical_check, anything, anything, anything)

        check.message

        expect(Admin::SystemCheck::Message).to have_received(:new)
          .with(:software_version_critical_check, nil, admin_software_updates_path, true)
      end
    end
  end
end

A spec/lib/importer/public_statuses_index_importer_spec.rb => spec/lib/importer/public_statuses_index_importer_spec.rb +16 -0
@@ 0,0 1,16 @@
# frozen_string_literal: true

require 'rails_helper'

describe Importer::PublicStatusesIndexImporter do
  describe 'import!' do
    let(:pool) { Concurrent::FixedThreadPool.new(5) }
    let(:importer) { described_class.new(batch_size: 123, executor: pool) }

    before { Fabricate(:status, account: Fabricate(:account, indexable: true)) }

    it 'indexes relevant statuses' do
      expect { importer.import! }.to update_index(PublicStatusesIndex)
    end
  end
end

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

require 'rails_helper'
require 'parslet/rig/rspec'

describe SearchQueryParser do
  let(:parser) { described_class.new }

  context 'with term' do
    it 'consumes "hello"' do
      expect(parser.term).to parse('hello')
    end
  end

  context 'with prefix' do
    it 'consumes "foo:"' do
      expect(parser.prefix).to parse('foo:')
    end
  end

  context 'with operator' do
    it 'consumes "+"' do
      expect(parser.operator).to parse('+')
    end

    it 'consumes "-"' do
      expect(parser.operator).to parse('-')
    end
  end

  context 'with shortcode' do
    it 'consumes ":foo:"' do
      expect(parser.shortcode).to parse(':foo:')
    end
  end

  context 'with phrase' do
    it 'consumes "hello world"' do
      expect(parser.phrase).to parse('"hello world"')
    end
  end

  context 'with clause' do
    it 'consumes "foo"' do
      expect(parser.clause).to parse('foo')
    end

    it 'consumes "-foo"' do
      expect(parser.clause).to parse('-foo')
    end

    it 'consumes "foo:bar"' do
      expect(parser.clause).to parse('foo:bar')
    end

    it 'consumes "-foo:bar"' do
      expect(parser.clause).to parse('-foo:bar')
    end

    it 'consumes \'foo:"hello world"\'' do
      expect(parser.clause).to parse('foo:"hello world"')
    end

    it 'consumes \'-foo:"hello world"\'' do
      expect(parser.clause).to parse('-foo:"hello world"')
    end

    it 'consumes "foo:"' do
      expect(parser.clause).to parse('foo:')
    end

    it 'consumes \'"\'' do
      expect(parser.clause).to parse('"')
    end
  end

  context 'with query' do
    it 'consumes "hello -world"' do
      expect(parser.query).to parse('hello -world')
    end

    it 'consumes \'foo "hello world"\'' do
      expect(parser.query).to parse('foo "hello world"')
    end

    it 'consumes "foo:bar hello"' do
      expect(parser.query).to parse('foo:bar hello')
    end

    it 'consumes \'"hello" world "\'' do
      expect(parser.query).to parse('"hello" world "')
    end

    it 'consumes "foo:bar bar: hello"' do
      expect(parser.query).to parse('foo:bar bar: hello')
    end
  end
end

M spec/lib/search_query_transformer_spec.rb => spec/lib/search_query_transformer_spec.rb +49 -8
@@ 3,16 3,57 @@
require 'rails_helper'

describe SearchQueryTransformer do
  describe 'initialization' do
    let(:parser) { SearchQueryParser.new.parse('query') }
  subject { described_class.new.apply(parser, current_account: nil) }

    it 'sets attributes' do
      transformer = described_class.new.apply(parser)
  let(:parser) { SearchQueryParser.new.parse(query) }

      expect(transformer.should_clauses.first).to be_a(SearchQueryTransformer::TermClause)
      expect(transformer.must_clauses.first).to be_nil
      expect(transformer.must_not_clauses.first).to be_nil
      expect(transformer.filter_clauses.first).to be_nil
  context 'with "hello world"' do
    let(:query) { 'hello world' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(hello world)
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses).to be_empty
    end
  end

  context 'with "hello -world"' do
    let(:query) { 'hello -world' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(hello)
      expect(subject.must_not_clauses.map(&:term)).to match_array %w(world)
      expect(subject.filter_clauses).to be_empty
    end
  end

  context 'with "hello is:reply"' do
    let(:query) { 'hello is:reply' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(hello)
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses.map(&:term)).to match_array %w(reply)
    end
  end

  context 'with "foo: bar"' do
    let(:query) { 'foo: bar' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(foo bar)
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses).to be_empty
    end
  end

  context 'with "foo:bar"' do
    let(:query) { 'foo:bar' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to contain_exactly('foo bar')
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses).to be_empty
    end
  end
end

M spec/mailers/admin_mailer_spec.rb => spec/mailers/admin_mailer_spec.rb +42 -0
@@ 85,4 85,46 @@ RSpec.describe AdminMailer do
      expect(mail.body.encoded).to match 'The following items need a review before they can be displayed publicly'
    end
  end

  describe '.new_software_updates' do
    let(:recipient) { Fabricate(:account, username: 'Bob') }
    let(:mail) { described_class.with(recipient: recipient).new_software_updates }

    before do
      recipient.user.update(locale: :en)
    end

    it 'renders the headers' do
      expect(mail.subject).to eq('New Mastodon versions are available for cb6e6126.ngrok.io!')
      expect(mail.to).to eq [recipient.user_email]
      expect(mail.from).to eq ['notifications@localhost']
    end

    it 'renders the body' do
      expect(mail.body.encoded).to match 'New Mastodon versions have been released, you may want to update!'
    end
  end

  describe '.new_critical_software_updates' do
    let(:recipient) { Fabricate(:account, username: 'Bob') }
    let(:mail) { described_class.with(recipient: recipient).new_critical_software_updates }

    before do
      recipient.user.update(locale: :en)
    end

    it 'renders the headers', :aggregate_failures do
      expect(mail.subject).to eq('Critical Mastodon updates are available for cb6e6126.ngrok.io!')
      expect(mail.to).to eq [recipient.user_email]
      expect(mail.from).to eq ['notifications@localhost']

      expect(mail['Importance'].value).to eq 'high'
      expect(mail['Priority'].value).to eq 'urgent'
      expect(mail['X-Priority'].value).to eq '1'
    end

    it 'renders the body' do
      expect(mail.body.encoded).to match 'New critical versions of Mastodon have been released, you may want to update as soon as possible!'
    end
  end
end

A spec/models/concerns/account_statuses_search_spec.rb => spec/models/concerns/account_statuses_search_spec.rb +66 -0
@@ 0,0 1,66 @@
# frozen_string_literal: true

require 'rails_helper'

describe AccountStatusesSearch do
  let(:account) { Fabricate(:account, indexable: indexable) }

  before do
    allow(Chewy).to receive(:enabled?).and_return(true)
  end

  describe '#enqueue_update_public_statuses_index' do
    before do
      allow(account).to receive(:enqueue_add_to_public_statuses_index)
      allow(account).to receive(:enqueue_remove_from_public_statuses_index)
    end

    context 'when account is indexable' do
      let(:indexable) { true }

      it 'enqueues add_to_public_statuses_index and not to remove_from_public_statuses_index' do
        account.enqueue_update_public_statuses_index
        expect(account).to have_received(:enqueue_add_to_public_statuses_index)
        expect(account).to_not have_received(:enqueue_remove_from_public_statuses_index)
      end
    end

    context 'when account is not indexable' do
      let(:indexable) { false }

      it 'enqueues remove_from_public_statuses_index and not to add_to_public_statuses_index' do
        account.enqueue_update_public_statuses_index
        expect(account).to have_received(:enqueue_remove_from_public_statuses_index)
        expect(account).to_not have_received(:enqueue_add_to_public_statuses_index)
      end
    end
  end

  describe '#enqueue_add_to_public_statuses_index' do
    let(:indexable) { true }
    let(:worker) { AddToPublicStatusesIndexWorker }

    before do
      allow(worker).to receive(:perform_async)
    end

    it 'enqueues AddToPublicStatusesIndexWorker' do
      account.enqueue_add_to_public_statuses_index
      expect(worker).to have_received(:perform_async).with(account.id)
    end
  end

  describe '#enqueue_remove_from_public_statuses_index' do
    let(:indexable) { false }
    let(:worker) { RemoveFromPublicStatusesIndexWorker }

    before do
      allow(worker).to receive(:perform_async)
    end

    it 'enqueues RemoveFromPublicStatusesIndexWorker' do
      account.enqueue_remove_from_public_statuses_index
      expect(worker).to have_received(:perform_async).with(account.id)
    end
  end
end

A spec/models/software_update_spec.rb => spec/models/software_update_spec.rb +87 -0
@@ 0,0 1,87 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe SoftwareUpdate do
  describe '.pending_to_a' do
    before do
      allow(Mastodon::Version).to receive(:gem_version).and_return(Gem::Version.new(mastodon_version))

      Fabricate(:software_update, version: '3.4.42', type: 'patch', urgent: true)
      Fabricate(:software_update, version: '3.5.0', type: 'minor', urgent: false)
      Fabricate(:software_update, version: '4.2.0', type: 'major', urgent: false)
    end

    context 'when the Mastodon version is an outdated release' do
      let(:mastodon_version) { '3.4.0' }

      it 'returns the expected versions' do
        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('3.4.42', '3.5.0', '4.2.0')
      end
    end

    context 'when the Mastodon version is more recent than anything last returned by the server' do
      let(:mastodon_version) { '5.0.0' }

      it 'returns the expected versions' do
        expect(described_class.pending_to_a.pluck(:version)).to eq []
      end
    end

    context 'when the Mastodon version is an outdated nightly' do
      let(:mastodon_version) { '4.3.0-nightly.2023-09-10' }

      before do
        Fabricate(:software_update, version: '4.3.0-nightly.2023-09-12', type: 'major', urgent: true)
      end

      it 'returns the expected versions' do
        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-nightly.2023-09-12')
      end
    end

    context 'when the Mastodon version is a very outdated nightly' do
      let(:mastodon_version) { '4.2.0-nightly.2023-07-10' }

      it 'returns the expected versions' do
        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.2.0')
      end
    end

    context 'when the Mastodon version is an outdated dev version' do
      let(:mastodon_version) { '4.3.0-0.dev.0' }

      before do
        Fabricate(:software_update, version: '4.3.0-0.dev.2', type: 'major', urgent: true)
      end

      it 'returns the expected versions' do
        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-0.dev.2')
      end
    end

    context 'when the Mastodon version is an outdated beta version' do
      let(:mastodon_version) { '4.3.0-beta1' }

      before do
        Fabricate(:software_update, version: '4.3.0-beta2', type: 'major', urgent: true)
      end

      it 'returns the expected versions' do
        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-beta2')
      end
    end

    context 'when the Mastodon version is an outdated beta version and there is a rc' do
      let(:mastodon_version) { '4.3.0-beta1' }

      before do
        Fabricate(:software_update, version: '4.3.0-rc1', type: 'major', urgent: true)
      end

      it 'returns the expected versions' do
        expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-rc1')
      end
    end
  end
end

A spec/policies/software_update_policy_spec.rb => spec/policies/software_update_policy_spec.rb +25 -0
@@ 0,0 1,25 @@
# frozen_string_literal: true

require 'rails_helper'
require 'pundit/rspec'

RSpec.describe SoftwareUpdatePolicy do
  subject { described_class }

  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Owner')).account }
  let(:john)    { Fabricate(:account) }

  permissions :index? do
    context 'when owner' do
      it 'permits' do
        expect(subject).to permit(admin, SoftwareUpdate)
      end
    end

    context 'when not owner' do
      it 'denies' do
        expect(subject).to_not permit(john, SoftwareUpdate)
      end
    end
  end
end

M spec/services/search_service_spec.rb => spec/services/search_service_spec.rb +0 -9
@@ 83,15 83,6 @@ describe SearchService, type: :service do
          expect(Tag).to have_received(:search_for).with('tag', 10, 0, exclude_unreviewed: nil)
          expect(results).to eq empty_results.merge(hashtags: [tag])
        end

        it 'does not include tag when starts with @ character' do
          query = '@username'
          allow(Tag).to receive(:search_for)

          results = subject.call(query, nil, 10)
          expect(Tag).to_not have_received(:search_for)
          expect(results).to eq empty_results
        end
      end
    end
  end

A spec/services/software_update_check_service_spec.rb => spec/services/software_update_check_service_spec.rb +158 -0
@@ 0,0 1,158 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe SoftwareUpdateCheckService, type: :service do
  subject { described_class.new }

  shared_examples 'when the feature is enabled' do
    let(:full_update_check_url) { "#{update_check_url}?version=#{Mastodon::Version.to_s.split('+')[0]}" }

    let(:devops_role)     { Fabricate(:user_role, name: 'DevOps', permissions: UserRole::FLAGS[:view_devops]) }
    let(:owner_user)      { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) }
    let(:old_devops_user) { Fabricate(:user) }
    let(:none_user)       { Fabricate(:user, role: devops_role) }
    let(:patch_user)      { Fabricate(:user, role: devops_role) }
    let(:critical_user)   { Fabricate(:user, role: devops_role) }

    around do |example|
      queue_adapter = ActiveJob::Base.queue_adapter
      ActiveJob::Base.queue_adapter = :test

      example.run

      ActiveJob::Base.queue_adapter = queue_adapter
    end

    before do
      Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false)
      Fabricate(:software_update, version: '42.13.12', type: 'major', urgent: false)

      owner_user.settings.update('notification_emails.software_updates': 'all')
      owner_user.save!

      old_devops_user.settings.update('notification_emails.software_updates': 'all')
      old_devops_user.save!

      none_user.settings.update('notification_emails.software_updates': 'none')
      none_user.save!

      patch_user.settings.update('notification_emails.software_updates': 'patch')
      patch_user.save!

      critical_user.settings.update('notification_emails.software_updates': 'critical')
      critical_user.save!
    end

    context 'when the update server errors out' do
      before do
        stub_request(:get, full_update_check_url).to_return(status: 404)
      end

      it 'deletes outdated update records but keeps valid update records' do
        expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['42.13.12'])
      end
    end

    context 'when the server returns new versions' do
      let(:server_json) do
        {
          updatesAvailable: [
            {
              version: '4.2.1',
              urgent: false,
              type: 'patch',
              releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.2.1',
            },
            {
              version: '4.3.0',
              urgent: false,
              type: 'minor',
              releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.3.0',
            },
            {
              version: '5.0.0',
              urgent: false,
              type: 'minor',
              releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0',
            },
          ],
        }
      end

      before do
        stub_request(:get, full_update_check_url).to_return(body: Oj.dump(server_json))
      end

      it 'updates the list of known updates' do
        expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['4.2.1', '4.3.0', '5.0.0'])
      end

      context 'when no update is urgent' do
        it 'sends e-mail notifications according to settings', :aggregate_failures do
          expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_software_updates)
            .with(hash_including(params: { recipient: owner_user.account })).once
            .and(have_enqueued_mail(AdminMailer, :new_software_updates).with(hash_including(params: { recipient: patch_user.account })).once)
            .and(have_enqueued_mail.at_most(2))
        end
      end

      context 'when an update is urgent' do
        let(:server_json) do
          {
            updatesAvailable: [
              {
                version: '5.0.0',
                urgent: true,
                type: 'minor',
                releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0',
              },
            ],
          }
        end

        it 'sends e-mail notifications according to settings', :aggregate_failures do
          expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_critical_software_updates)
            .with(hash_including(params: { recipient: owner_user.account })).once
            .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: patch_user.account })).once)
            .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: critical_user.account })).once)
            .and(have_enqueued_mail.at_most(3))
        end
      end
    end
  end

  context 'when update checking is disabled' do
    around do |example|
      ClimateControl.modify UPDATE_CHECK_URL: '' do
        example.run
      end
    end

    before do
      Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false)
    end

    it 'deletes outdated update records' do
      expect { subject.call }.to change(SoftwareUpdate, :count).from(1).to(0)
    end
  end

  context 'when using the default update checking API' do
    let(:update_check_url) { 'https://api.joinmastodon.org/update-check' }

    it_behaves_like 'when the feature is enabled'
  end

  context 'when using a custom update check URL' do
    let(:update_check_url) { 'https://api.example.com/update_check' }

    around do |example|
      ClimateControl.modify UPDATE_CHECK_URL: 'https://api.example.com/update_check' do
        example.run
      end
    end

    it_behaves_like 'when the feature is enabled'
  end
end

A spec/workers/add_to_public_statuses_index_worker_spec.rb => spec/workers/add_to_public_statuses_index_worker_spec.rb +42 -0
@@ 0,0 1,42 @@
# frozen_string_literal: true

require 'rails_helper'

describe AddToPublicStatusesIndexWorker do
  describe '#perform' do
    let(:account) { Fabricate(:account, indexable: indexable) }
    let(:account_id) { account.id }

    before do
      allow(Account).to receive(:find).with(account_id).and_return(account) unless account.nil?
      allow(account).to receive(:add_to_public_statuses_index!) unless account.nil?
    end

    context 'when account is indexable' do
      let(:indexable) { true }

      it 'adds the account to the public statuses index' do
        subject.perform(account_id)
        expect(account).to have_received(:add_to_public_statuses_index!)
      end
    end

    context 'when account is not indexable' do
      let(:indexable) { false }

      it 'does not add the account to public statuses index' do
        subject.perform(account_id)
        expect(account).to_not have_received(:add_to_public_statuses_index!)
      end
    end

    context 'when account does not exist' do
      let(:account) { nil }
      let(:account_id) { 999 }

      it 'does not raise an error' do
        expect { subject.perform(account_id) }.to_not raise_error
      end
    end
  end
end

A spec/workers/remove_from_public_statuses_index_worker_spec.rb => spec/workers/remove_from_public_statuses_index_worker_spec.rb +42 -0
@@ 0,0 1,42 @@
# frozen_string_literal: true

require 'rails_helper'

describe RemoveFromPublicStatusesIndexWorker do
  describe '#perform' do
    let(:account) { Fabricate(:account, indexable: indexable) }
    let(:account_id) { account.id }

    before do
      allow(Account).to receive(:find).with(account_id).and_return(account) unless account.nil?
      allow(account).to receive(:remove_from_public_statuses_index!) unless account.nil?
    end

    context 'when account is not indexable' do
      let(:indexable) { false }

      it 'removes the account from public statuses index' do
        subject.perform(account_id)
        expect(account).to have_received(:remove_from_public_statuses_index!)
      end
    end

    context 'when account is indexable' do
      let(:indexable) { true }

      it 'does not remove the account from public statuses index' do
        subject.perform(account_id)
        expect(account).to_not have_received(:remove_from_public_statuses_index!)
      end
    end

    context 'when account does not exist' do
      let(:account) { nil }
      let(:account_id) { 999 }

      it 'does not raise an error' do
        expect { subject.perform(account_id) }.to_not raise_error
      end
    end
  end
end

A spec/workers/scheduler/software_update_check_scheduler_spec.rb => spec/workers/scheduler/software_update_check_scheduler_spec.rb +20 -0
@@ 0,0 1,20 @@
# frozen_string_literal: true

require 'rails_helper'

describe Scheduler::SoftwareUpdateCheckScheduler do
  subject { described_class.new }

  describe 'perform' do
    let(:service_double) { instance_double(SoftwareUpdateCheckService, call: nil) }

    before do
      allow(SoftwareUpdateCheckService).to receive(:new).and_return(service_double)
    end

    it 'calls SoftwareUpdateCheckService' do
      subject.perform
      expect(service_double).to have_received(:call)
    end
  end
end

M streaming/index.js => streaming/index.js +38 -44
@@ 6,12 6,12 @@ const url = require('url');

const dotenv = require('dotenv');
const express = require('express');
const Redis = require('ioredis');
const { JSDOM } = require('jsdom');
const log = require('npmlog');
const pg = require('pg');
const dbUrlToConfig = require('pg-connection-string').parse;
const metrics = require('prom-client');
const redis = require('redis');
const uuid = require('uuid');
const WebSocket = require('ws');



@@ 24,30 24,12 @@ dotenv.config({
log.level = process.env.LOG_LEVEL || 'verbose';

/**
 * @param {Object.<string, any>} defaultConfig
 * @param {string} redisUrl
 * @param {Object.<string, any>} config
 */
const redisUrlToClient = async (defaultConfig, redisUrl) => {
  const config = defaultConfig;

  let client;

  if (!redisUrl) {
    client = redis.createClient(config);
  } else if (redisUrl.startsWith('unix://')) {
    client = redis.createClient(Object.assign(config, {
      socket: {
        path: redisUrl.slice(7),
      },
    }));
  } else {
    client = redis.createClient(Object.assign(config, {
      url: redisUrl,
    }));
  }

const createRedisClient = async (config) => {
  const { redisParams, redisUrl } = config;
  const client = new Redis(redisUrl, redisParams);
  client.on('error', (err) => log.error('Redis Client Error!', err));
  await client.connect();

  return client;
};


@@ 147,23 129,22 @@ const pgConfigFromEnv = (env) => {
 * @returns {Object.<string, any>} configuration for the Redis connection
 */
const redisConfigFromEnv = (env) => {
  const redisNamespace = env.REDIS_NAMESPACE || null;
  // ioredis *can* transparently add prefixes for us, but it doesn't *in some cases*,
  // which means we can't use it. But this is something that should be looked into.
  const redisPrefix = env.REDIS_NAMESPACE ? `${env.REDIS_NAMESPACE}:` : '';

  const redisParams = {
    socket: {
      host: env.REDIS_HOST || '127.0.0.1',
      port: env.REDIS_PORT || 6379,
    },
    database: env.REDIS_DB || 0,
    host: env.REDIS_HOST || '127.0.0.1',
    port: env.REDIS_PORT || 6379,
    db: env.REDIS_DB || 0,
    password: env.REDIS_PASSWORD || undefined,
  };

  if (redisNamespace) {
    redisParams.namespace = redisNamespace;
  // redisParams.path takes precedence over host and port.
  if (env.REDIS_URL && env.REDIS_URL.startsWith('unix://')) {
    redisParams.path = env.REDIS_URL.slice(7);
  }

  const redisPrefix = redisNamespace ? `${redisNamespace}:` : '';

  return {
    redisParams,
    redisPrefix,


@@ 179,15 160,15 @@ const startServer = async () => {
  const pgPool = new pg.Pool(pgConfigFromEnv(process.env));
  const server = http.createServer(app);

  const { redisParams, redisUrl, redisPrefix } = redisConfigFromEnv(process.env);

  /**
   * @type {Object.<string, Array.<function(Object<string, any>): void>>}
   */
  const subs = {};

  const redisSubscribeClient = await redisUrlToClient(redisParams, redisUrl);
  const redisClient = await redisUrlToClient(redisParams, redisUrl);
  const redisConfig = redisConfigFromEnv(process.env);
  const redisSubscribeClient = await createRedisClient(redisConfig);
  const redisClient = await createRedisClient(redisConfig);
  const { redisPrefix } = redisConfig;

  // Collect metrics from Node.js
  metrics.collectDefaultMetrics();


@@ 277,13 258,13 @@ const startServer = async () => {
  };

  /**
   * @param {string} message
   * @param {string} channel
   * @param {string} message
   */
  const onRedisMessage = (message, channel) => {
  const onRedisMessage = (channel, message) => {
    const callbacks = subs[channel];

    log.silly(`New message on channel ${channel}`);
    log.silly(`New message on channel ${redisPrefix}${channel}`);

    if (!callbacks) {
      return;


@@ 294,6 275,7 @@ const startServer = async () => {

    callbacks.forEach(callback => callback(json));
  };
  redisSubscribeClient.on("message", onRedisMessage);

  /**
   * @callback SubscriptionListener


@@ 312,8 294,14 @@ const startServer = async () => {

    if (subs[channel].length === 0) {
      log.verbose(`Subscribe ${channel}`);
      redisSubscribeClient.subscribe(channel, onRedisMessage);
      redisSubscriptions.inc();
      redisSubscribeClient.subscribe(channel, (err, count) => {
        if (err) {
          log.error(`Error subscribing to ${channel}`);
        }
        else {
          redisSubscriptions.set(count);
        }
      });
    }

    subs[channel].push(callback);


@@ 334,8 322,14 @@ const startServer = async () => {

    if (subs[channel].length === 0) {
      log.verbose(`Unsubscribe ${channel}`);
      redisSubscribeClient.unsubscribe(channel);
      redisSubscriptions.dec();
      redisSubscribeClient.unsubscribe(channel, (err, count) => {
        if (err) {
          log.error(`Error unsubscribing to ${channel}`);
        }
        else {
          redisSubscriptions.set(count);
        }
      });
      delete subs[channel];
    }
  };

M yarn.lock => yarn.lock +445 -366
@@ 29,7 29,7 @@
    jsonpointer "^5.0.0"
    leven "^3.1.0"

"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.22.10", "@babel/code-frame@^7.22.5":
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.22.10":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.10.tgz#1c20e612b768fefa75f6e90d6ecb86329247f0a3"
  integrity sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==


@@ 44,30 44,38 @@
  dependencies:
    "@babel/highlight" "^7.22.5"

"@babel/compat-data@^7.22.5", "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9":
"@babel/code-frame@^7.22.5":
  version "7.22.13"
  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
  dependencies:
    "@babel/highlight" "^7.22.13"
    chalk "^2.4.2"

"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9":
  version "7.22.9"
  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730"
  integrity sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==

"@babel/core@^7.10.4", "@babel/core@^7.11.1", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.22.1":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.10.tgz#aad442c7bcd1582252cb4576747ace35bc122f35"
  integrity sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.11.tgz#8033acaa2aa24c3f814edaaa057f3ce0ba559c24"
  integrity sha512-lh7RJrtPdhibbxndr6/xx0w8+CVlY5FJZiaSz908Fpy+G0xkBFTvwLcKJFF4PJxVfGhVWNebikpWGnOoC71juQ==
  dependencies:
    "@ampproject/remapping" "^2.2.0"
    "@babel/code-frame" "^7.22.10"
    "@babel/generator" "^7.22.10"
    "@babel/helper-compilation-targets" "^7.22.10"
    "@babel/helper-module-transforms" "^7.22.9"
    "@babel/helpers" "^7.22.10"
    "@babel/parser" "^7.22.10"
    "@babel/helpers" "^7.22.11"
    "@babel/parser" "^7.22.11"
    "@babel/template" "^7.22.5"
    "@babel/traverse" "^7.22.10"
    "@babel/types" "^7.22.10"
    "@babel/traverse" "^7.22.11"
    "@babel/types" "^7.22.11"
    convert-source-map "^1.7.0"
    debug "^4.1.0"
    gensync "^1.0.0-beta.2"
    json5 "^2.2.2"
    json5 "^2.2.3"
    semver "^6.3.1"

"@babel/generator@^7.22.10", "@babel/generator@^7.22.5", "@babel/generator@^7.7.2":


@@ 113,10 121,10 @@
    lru-cache "^5.1.1"
    semver "^6.3.1"

"@babel/helper-create-class-features-plugin@^7.22.5":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.10.tgz#dd2612d59eac45588021ac3d6fa976d08f4e95a3"
  integrity sha512-5IBb77txKYQPpOEdUdIhBx8VrZyDCQ+H82H0+5dX1TmuscP5vJKEE3cKurjtIw/vFwzbVH48VweE78kVDBrqjA==
"@babel/helper-create-class-features-plugin@^7.22.11", "@babel/helper-create-class-features-plugin@^7.22.5":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.11.tgz#4078686740459eeb4af3494a273ac09148dfb213"
  integrity sha512-y1grdYL4WzmUDBRGK0pDbIoFd7UZKoDurDzWEoNMYoj1EL+foGRQNyPWDcC+YyegN5y1DUsFFmzjGijB3nSVAQ==
  dependencies:
    "@babel/helper-annotate-as-pure" "^7.22.5"
    "@babel/helper-environment-visitor" "^7.22.5"


@@ 268,16 276,25 @@
    "@babel/template" "^7.22.5"
    "@babel/types" "^7.22.10"

"@babel/helpers@^7.22.10":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.10.tgz#ae6005c539dfbcb5cd71fb51bfc8a52ba63bc37a"
  integrity sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==
"@babel/helpers@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.11.tgz#b02f5d5f2d7abc21ab59eeed80de410ba70b056a"
  integrity sha512-vyOXC8PBWaGc5h7GMsNx68OH33cypkEDJCHvYVVgVbbxJDROYVtexSk0gK5iCF1xNjRIN2s8ai7hwkWDq5szWg==
  dependencies:
    "@babel/template" "^7.22.5"
    "@babel/traverse" "^7.22.10"
    "@babel/types" "^7.22.10"
    "@babel/traverse" "^7.22.11"
    "@babel/types" "^7.22.11"

"@babel/highlight@^7.22.10", "@babel/highlight@^7.22.5":
"@babel/highlight@^7.22.10", "@babel/highlight@^7.22.13":
  version "7.22.13"
  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.13.tgz#9cda839e5d3be9ca9e8c26b6dd69e7548f0cbf16"
  integrity sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==
  dependencies:
    "@babel/helper-validator-identifier" "^7.22.5"
    chalk "^2.4.2"
    js-tokens "^4.0.0"

"@babel/highlight@^7.22.5":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.10.tgz#02a3f6d8c1cb4521b2fd0ab0da8f4739936137d7"
  integrity sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==


@@ 286,11 303,21 @@
    chalk "^2.4.2"
    js-tokens "^4.0.0"

"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.22.10", "@babel/parser@^7.22.5":
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.10.tgz#e37634f9a12a1716136c44624ef54283cabd3f55"
  integrity sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==

"@babel/parser@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.11.tgz#becf8ee33aad2a35ed5607f521fe6e72a615f905"
  integrity sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==

"@babel/parser@^7.22.5":
  version "7.22.14"
  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.14.tgz#c7de58e8de106e88efca42ce17f0033209dfd245"
  integrity sha512-1KucTHgOvaw/LzCVrEOAyXkr9rQlp0A1HiHRYnSUE9dmb8PvPW7o5sscg+5169r54n3vGlbx6GevTE/Iw/P3AQ==

"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz#87245a21cd69a73b0b81bcda98d443d6df08f05e"


@@ 467,10 494,10 @@
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-async-generator-functions@^7.22.10":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.10.tgz#45946cd17f915b10e65c29b8ed18a0a50fc648c8"
  integrity sha512-eueE8lvKVzq5wIObKK/7dvoeKJ+xc6TvRn6aysIjS6pSCeLy7S/eVi7pEQknZqyqvzaNKdDtem8nUNTBgDVR2g==
"@babel/plugin-transform-async-generator-functions@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.11.tgz#dbe3b1ff5a52e2e5edc4b19a60d325a675ed2649"
  integrity sha512-0pAlmeRJn6wU84zzZsEOx1JV1Jf8fqO9ok7wofIJwUnplYo247dcd24P+cMJht7ts9xkzdtB0EPHmOb7F+KzXw==
  dependencies:
    "@babel/helper-environment-visitor" "^7.22.5"
    "@babel/helper-plugin-utils" "^7.22.5"


@@ 508,12 535,12 @@
    "@babel/helper-create-class-features-plugin" "^7.22.5"
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-class-static-block@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.5.tgz#3e40c46f048403472d6f4183116d5e46b1bff5ba"
  integrity sha512-SPToJ5eYZLxlnp1UzdARpOGeC2GbHvr9d/UV0EukuVx8atktg194oe+C5BqQ8jRTkgLRVOPYeXRSBg1IlMoVRA==
"@babel/plugin-transform-class-static-block@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz#dc8cc6e498f55692ac6b4b89e56d87cec766c974"
  integrity sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g==
  dependencies:
    "@babel/helper-create-class-features-plugin" "^7.22.5"
    "@babel/helper-create-class-features-plugin" "^7.22.11"
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-class-static-block" "^7.14.5"



@@ 562,10 589,10 @@
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-dynamic-import@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.5.tgz#d6908a8916a810468c4edff73b5b75bda6ad393e"
  integrity sha512-0MC3ppTB1AMxd8fXjSrbPa7LT9hrImt+/fcj+Pg5YMD7UQyWp/02+JWpdnCymmsXwIx5Z+sYn1bwCn4ZJNvhqQ==
"@babel/plugin-transform-dynamic-import@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz#2c7722d2a5c01839eaf31518c6ff96d408e447aa"
  integrity sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-dynamic-import" "^7.8.3"


@@ 578,10 605,10 @@
    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.22.5"
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-export-namespace-from@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.5.tgz#57c41cb1d0613d22f548fddd8b288eedb9973a5b"
  integrity sha512-X4hhm7FRnPgd4nDA4b/5V280xCx6oL7Oob5+9qVS5C13Zq4bh1qq7LU0GgRU6b5dBWBvhGaXYVB4AcN6+ol6vg==
"@babel/plugin-transform-export-namespace-from@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz#b3c84c8f19880b6c7440108f8929caf6056db26c"
  integrity sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"


@@ 602,10 629,10 @@
    "@babel/helper-function-name" "^7.22.5"
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-json-strings@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.5.tgz#14b64352fdf7e1f737eed68de1a1468bd2a77ec0"
  integrity sha512-DuCRB7fu8MyTLbEQd1ew3R85nx/88yMoqo2uPSjevMj3yoN7CDM8jkgrY0wmVxfJZyJ/B9fE1iq7EQppWQmR5A==
"@babel/plugin-transform-json-strings@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz#689a34e1eed1928a40954e37f74509f48af67835"
  integrity sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-json-strings" "^7.8.3"


@@ 617,10 644,10 @@
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-logical-assignment-operators@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.5.tgz#66ae5f068fd5a9a5dc570df16f56c2a8462a9d6c"
  integrity sha512-MQQOUW1KL8X0cDWfbwYP+TbVbZm16QmQXJQ+vndPtH/BoO0lOKpVoEDMI7+PskYxH+IiE0tS8xZye0qr1lGzSA==
"@babel/plugin-transform-logical-assignment-operators@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz#24c522a61688bde045b7d9bc3c2597a4d948fc9c"
  integrity sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"


@@ 640,22 667,22 @@
    "@babel/helper-module-transforms" "^7.22.5"
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-modules-commonjs@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.5.tgz#7d9875908d19b8c0536085af7b053fd5bd651bfa"
  integrity sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==
"@babel/plugin-transform-modules-commonjs@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.11.tgz#d7991d3abad199c03b68ee66a64f216c47ffdfae"
  integrity sha512-o2+bg7GDS60cJMgz9jWqRUsWkMzLCxp+jFDeDUT5sjRlAxcJWZ2ylNdI7QQ2+CH5hWu7OnN+Cv3htt7AkSf96g==
  dependencies:
    "@babel/helper-module-transforms" "^7.22.5"
    "@babel/helper-module-transforms" "^7.22.9"
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/helper-simple-access" "^7.22.5"

"@babel/plugin-transform-modules-systemjs@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.5.tgz#18c31410b5e579a0092638f95c896c2a98a5d496"
  integrity sha512-emtEpoaTMsOs6Tzz+nbmcePl6AKVtS1yC4YNAeMun9U8YCsgadPNxnOPQ8GhHFB2qdx+LZu9LgoC0Lthuu05DQ==
"@babel/plugin-transform-modules-systemjs@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.11.tgz#3386be5875d316493b517207e8f1931d93154bb1"
  integrity sha512-rIqHmHoMEOhI3VkVf5jQ15l539KrwhzqcBO6wdCNWPWc/JWt9ILNYNUssbRpeq0qWns8svuw8LnMNCvWBIJ8wA==
  dependencies:
    "@babel/helper-hoist-variables" "^7.22.5"
    "@babel/helper-module-transforms" "^7.22.5"
    "@babel/helper-module-transforms" "^7.22.9"
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/helper-validator-identifier" "^7.22.5"



@@ 682,29 709,29 @@
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-nullish-coalescing-operator@^7.22.3", "@babel/plugin-transform-nullish-coalescing-operator@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.5.tgz#f8872c65776e0b552e0849d7596cddd416c3e381"
  integrity sha512-6CF8g6z1dNYZ/VXok5uYkkBBICHZPiGEl7oDnAx2Mt1hlHVHOSIKWJaXHjQJA5VB43KZnXZDIexMchY4y2PGdA==
"@babel/plugin-transform-nullish-coalescing-operator@^7.22.11", "@babel/plugin-transform-nullish-coalescing-operator@^7.22.3":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz#debef6c8ba795f5ac67cd861a81b744c5d38d9fc"
  integrity sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"

"@babel/plugin-transform-numeric-separator@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.5.tgz#57226a2ed9e512b9b446517ab6fa2d17abb83f58"
  integrity sha512-NbslED1/6M+sXiwwtcAB/nieypGw02Ejf4KtDeMkCEpP6gWFMX1wI9WKYua+4oBneCCEmulOkRpwywypVZzs/g==
"@babel/plugin-transform-numeric-separator@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz#498d77dc45a6c6db74bb829c02a01c1d719cbfbd"
  integrity sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-numeric-separator" "^7.10.4"

"@babel/plugin-transform-object-rest-spread@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.5.tgz#9686dc3447df4753b0b2a2fae7e8bc33cdc1f2e1"
  integrity sha512-Kk3lyDmEslH9DnvCDA1s1kkd3YWQITiBOHngOtDL9Pt6BZjzqb6hiOlb8VfjiiQJ2unmegBqZu0rx5RxJb5vmQ==
"@babel/plugin-transform-object-rest-spread@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.11.tgz#dbbb06ce783cd994a8f430d8cefa553e9b42ca62"
  integrity sha512-nX8cPFa6+UmbepISvlf5jhQyaC7ASs/7UxHmMkuJ/k5xSHvDPPaibMo+v3TXwU/Pjqhep/nFNpd3zn4YR59pnw==
  dependencies:
    "@babel/compat-data" "^7.22.5"
    "@babel/helper-compilation-targets" "^7.22.5"
    "@babel/compat-data" "^7.22.9"
    "@babel/helper-compilation-targets" "^7.22.10"
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
    "@babel/plugin-transform-parameters" "^7.22.5"


@@ 717,18 744,18 @@
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/helper-replace-supers" "^7.22.5"

"@babel/plugin-transform-optional-catch-binding@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.5.tgz#842080be3076703be0eaf32ead6ac8174edee333"
  integrity sha512-pH8orJahy+hzZje5b8e2QIlBWQvGpelS76C63Z+jhZKsmzfNaPQ+LaW6dcJ9bxTpo1mtXbgHwy765Ro3jftmUg==
"@babel/plugin-transform-optional-catch-binding@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz#461cc4f578a127bb055527b3e77404cad38c08e0"
  integrity sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"

"@babel/plugin-transform-optional-chaining@^7.22.10", "@babel/plugin-transform-optional-chaining@^7.22.5":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.10.tgz#076d28a7e074392e840d4ae587d83445bac0372a"
  integrity sha512-MMkQqZAZ+MGj+jGTG3OTuhKeBpNcO+0oCEbrGNEaOmiEn+1MzRyQlYsruGiU8RTK3zV6XwrVJTmwiDOyYK6J9g==
"@babel/plugin-transform-optional-chaining@^7.22.12", "@babel/plugin-transform-optional-chaining@^7.22.5":
  version "7.22.12"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.12.tgz#d7ebf6a88cd2f4d307b0e000ab630acd8124b333"
  integrity sha512-7XXCVqZtyFWqjDsYDY4T45w4mlx1rf7aOgkc/Ww76xkgBiOlmjPkx36PBLHa1k1rwWvVgYMPsbuVnIamx2ZQJw==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"


@@ 749,13 776,13 @@
    "@babel/helper-create-class-features-plugin" "^7.22.5"
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-private-property-in-object@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.5.tgz#07a77f28cbb251546a43d175a1dda4cf3ef83e32"
  integrity sha512-/9xnaTTJcVoBtSSmrVyhtSvO3kbqS2ODoh2juEU72c3aYonNF0OMGiaz2gjukyKM2wBBYJP38S4JiE0Wfb5VMQ==
"@babel/plugin-transform-private-property-in-object@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz#ad45c4fc440e9cb84c718ed0906d96cf40f9a4e1"
  integrity sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ==
  dependencies:
    "@babel/helper-annotate-as-pure" "^7.22.5"
    "@babel/helper-create-class-features-plugin" "^7.22.5"
    "@babel/helper-create-class-features-plugin" "^7.22.11"
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-private-property-in-object" "^7.14.5"



@@ 877,13 904,13 @@
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-typescript@^7.22.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.22.5.tgz#5c0f7adfc1b5f38c4dbc8f79b1f0f8074134bd7d"
  integrity sha512-SMubA9S7Cb5sGSFFUlqxyClTA9zWJ8qGQrppNUm05LtFuN1ELRFNndkix4zUJrC9F+YivWwa1dHMSyo0e0N9dA==
"@babel/plugin-transform-typescript@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.22.11.tgz#9f27fb5e51585729374bb767ab6a6d9005a23329"
  integrity sha512-0E4/L+7gfvHub7wsbTv03oRtD69X31LByy44fGmFzbZScpupFByMcgCJ0VbBTkzyjSJKuRoGN8tcijOWKTmqOA==
  dependencies:
    "@babel/helper-annotate-as-pure" "^7.22.5"
    "@babel/helper-create-class-features-plugin" "^7.22.5"
    "@babel/helper-create-class-features-plugin" "^7.22.11"
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/plugin-syntax-typescript" "^7.22.5"



@@ 919,9 946,9 @@
    "@babel/helper-plugin-utils" "^7.22.5"

"@babel/preset-env@^7.11.0", "@babel/preset-env@^7.12.1", "@babel/preset-env@^7.22.4":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.10.tgz#3263b9fe2c8823d191d28e61eac60a79f9ce8a0f"
  integrity sha512-riHpLb1drNkpLlocmSyEg4oYJIQFeXAK/d7rI6mbD0XsvoTOOweXDmQPG/ErxsEhWk3rl3Q/3F6RFQlVFS8m0A==
  version "7.22.14"
  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.14.tgz#1cbb468d899f64fa71c53446f13b7ff8c0005cc1"
  integrity sha512-daodMIoVo+ol/g+//c/AH+szBkFj4STQUikvBijRGL72Ph+w+AMTSh55DUETe8KJlPlDT1k/mp7NBfOuiWmoig==
  dependencies:
    "@babel/compat-data" "^7.22.9"
    "@babel/helper-compilation-targets" "^7.22.10"


@@ 949,41 976,41 @@
    "@babel/plugin-syntax-top-level-await" "^7.14.5"
    "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6"
    "@babel/plugin-transform-arrow-functions" "^7.22.5"
    "@babel/plugin-transform-async-generator-functions" "^7.22.10"
    "@babel/plugin-transform-async-generator-functions" "^7.22.11"
    "@babel/plugin-transform-async-to-generator" "^7.22.5"
    "@babel/plugin-transform-block-scoped-functions" "^7.22.5"
    "@babel/plugin-transform-block-scoping" "^7.22.10"
    "@babel/plugin-transform-class-properties" "^7.22.5"
    "@babel/plugin-transform-class-static-block" "^7.22.5"
    "@babel/plugin-transform-class-static-block" "^7.22.11"
    "@babel/plugin-transform-classes" "^7.22.6"
    "@babel/plugin-transform-computed-properties" "^7.22.5"
    "@babel/plugin-transform-destructuring" "^7.22.10"
    "@babel/plugin-transform-dotall-regex" "^7.22.5"
    "@babel/plugin-transform-duplicate-keys" "^7.22.5"
    "@babel/plugin-transform-dynamic-import" "^7.22.5"
    "@babel/plugin-transform-dynamic-import" "^7.22.11"
    "@babel/plugin-transform-exponentiation-operator" "^7.22.5"
    "@babel/plugin-transform-export-namespace-from" "^7.22.5"
    "@babel/plugin-transform-export-namespace-from" "^7.22.11"
    "@babel/plugin-transform-for-of" "^7.22.5"
    "@babel/plugin-transform-function-name" "^7.22.5"
    "@babel/plugin-transform-json-strings" "^7.22.5"
    "@babel/plugin-transform-json-strings" "^7.22.11"
    "@babel/plugin-transform-literals" "^7.22.5"
    "@babel/plugin-transform-logical-assignment-operators" "^7.22.5"
    "@babel/plugin-transform-logical-assignment-operators" "^7.22.11"
    "@babel/plugin-transform-member-expression-literals" "^7.22.5"
    "@babel/plugin-transform-modules-amd" "^7.22.5"
    "@babel/plugin-transform-modules-commonjs" "^7.22.5"
    "@babel/plugin-transform-modules-systemjs" "^7.22.5"
    "@babel/plugin-transform-modules-commonjs" "^7.22.11"
    "@babel/plugin-transform-modules-systemjs" "^7.22.11"
    "@babel/plugin-transform-modules-umd" "^7.22.5"
    "@babel/plugin-transform-named-capturing-groups-regex" "^7.22.5"
    "@babel/plugin-transform-new-target" "^7.22.5"
    "@babel/plugin-transform-nullish-coalescing-operator" "^7.22.5"
    "@babel/plugin-transform-numeric-separator" "^7.22.5"
    "@babel/plugin-transform-object-rest-spread" "^7.22.5"
    "@babel/plugin-transform-nullish-coalescing-operator" "^7.22.11"
    "@babel/plugin-transform-numeric-separator" "^7.22.11"
    "@babel/plugin-transform-object-rest-spread" "^7.22.11"
    "@babel/plugin-transform-object-super" "^7.22.5"
    "@babel/plugin-transform-optional-catch-binding" "^7.22.5"
    "@babel/plugin-transform-optional-chaining" "^7.22.10"
    "@babel/plugin-transform-optional-catch-binding" "^7.22.11"
    "@babel/plugin-transform-optional-chaining" "^7.22.12"
    "@babel/plugin-transform-parameters" "^7.22.5"
    "@babel/plugin-transform-private-methods" "^7.22.5"
    "@babel/plugin-transform-private-property-in-object" "^7.22.5"
    "@babel/plugin-transform-private-property-in-object" "^7.22.11"
    "@babel/plugin-transform-property-literals" "^7.22.5"
    "@babel/plugin-transform-regenerator" "^7.22.10"
    "@babel/plugin-transform-reserved-words" "^7.22.5"


@@ 997,7 1024,7 @@
    "@babel/plugin-transform-unicode-regex" "^7.22.5"
    "@babel/plugin-transform-unicode-sets-regex" "^7.22.5"
    "@babel/preset-modules" "0.1.6-no-external-plugins"
    "@babel/types" "^7.22.10"
    "@babel/types" "^7.22.11"
    babel-plugin-polyfill-corejs2 "^0.4.5"
    babel-plugin-polyfill-corejs3 "^0.8.3"
    babel-plugin-polyfill-regenerator "^0.5.2"


@@ 1026,15 1053,15 @@
    "@babel/plugin-transform-react-pure-annotations" "^7.22.5"

"@babel/preset-typescript@^7.21.5":
  version "7.22.5"
  resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.22.5.tgz#16367d8b01d640e9a507577ed4ee54e0101e51c8"
  integrity sha512-YbPaal9LxztSGhmndR46FmAbkJ/1fAsw293tSU+I5E5h+cnJ3d4GTwyUgGYmOXJYdGA+uNePle4qbaRzj2NISQ==
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.22.11.tgz#f218cd0345524ac888aa3dc32f029de5b064b575"
  integrity sha512-tWY5wyCZYBGY7IlalfKI1rLiGlIfnwsRHZqlky0HVv8qviwQ1Uo/05M6+s+TcTCVa6Bmoo2uJW5TMFX6Wa4qVg==
  dependencies:
    "@babel/helper-plugin-utils" "^7.22.5"
    "@babel/helper-validator-option" "^7.22.5"
    "@babel/plugin-syntax-jsx" "^7.22.5"
    "@babel/plugin-transform-modules-commonjs" "^7.22.5"
    "@babel/plugin-transform-typescript" "^7.22.5"
    "@babel/plugin-transform-modules-commonjs" "^7.22.11"
    "@babel/plugin-transform-typescript" "^7.22.11"

"@babel/regjsgen@^0.8.0":
  version "0.8.0"


@@ 1049,9 1076,9 @@
    regenerator-runtime "^0.12.0"

"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.22.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682"
  integrity sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.11.tgz#7a9ba3bbe406ad6f9e8dd4da2ece453eb23a77a4"
  integrity sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==
  dependencies:
    regenerator-runtime "^0.14.0"



@@ 1080,10 1107,10 @@
    debug "^4.1.0"
    globals "^11.1.0"

"@babel/traverse@^7.22.10":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.10.tgz#20252acb240e746d27c2e82b4484f199cf8141aa"
  integrity sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==
"@babel/traverse@^7.22.11":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.11.tgz#71ebb3af7a05ff97280b83f05f8865ac94b2027c"
  integrity sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==
  dependencies:
    "@babel/code-frame" "^7.22.10"
    "@babel/generator" "^7.22.10"


@@ 1091,12 1118,12 @@
    "@babel/helper-function-name" "^7.22.5"
    "@babel/helper-hoist-variables" "^7.22.5"
    "@babel/helper-split-export-declaration" "^7.22.6"
    "@babel/parser" "^7.22.10"
    "@babel/types" "^7.22.10"
    "@babel/parser" "^7.22.11"
    "@babel/types" "^7.22.11"
    debug "^4.1.0"
    globals "^11.1.0"

"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.5", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.3.3":
  version "7.22.10"
  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
  integrity sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==


@@ 1114,6 1141,15 @@
    "@babel/helper-validator-identifier" "^7.22.5"
    to-fast-properties "^2.0.0"

"@babel/types@^7.22.10", "@babel/types@^7.22.11", "@babel/types@^7.22.5", "@babel/types@^7.4.4":
  version "7.22.11"
  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.11.tgz#0e65a6a1d4d9cbaa892b2213f6159485fe632ea2"
  integrity sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==
  dependencies:
    "@babel/helper-string-parser" "^7.22.5"
    "@babel/helper-validator-identifier" "^7.22.5"
    to-fast-properties "^2.0.0"

"@bcoe/v8-coverage@^0.2.3":
  version "0.2.3"
  resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"


@@ 1249,9 1285,9 @@
    eslint-visitor-keys "^3.3.0"

"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1":
  version "4.6.2"
  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.6.2.tgz#1816b5f6948029c5eaacb0703b850ee0cb37d8f8"
  integrity sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==
  version "4.8.0"
  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.0.tgz#11195513186f68d42fbf449f9a7136b2c0c92005"
  integrity sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg==

"@eslint/eslintrc@^2.1.2":
  version "2.1.2"


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

"@eslint/js@^8.47.0":
  version "8.47.0"
  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.47.0.tgz#5478fdf443ff8158f9de171c704ae45308696c7d"
  integrity sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==
"@eslint/js@8.48.0":
  version "8.48.0"
  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.48.0.tgz#642633964e217905436033a2bd08bf322849b7fb"
  integrity sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==

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


@@ 1416,6 1452,11 @@
  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
  integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==

"@ioredis/commands@^1.1.1":
  version "1.2.0"
  resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11"
  integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==

"@isaacs/cliui@^8.0.2":
  version "8.0.2"
  resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"


@@ 1500,12 1541,12 @@
    "@types/node" "*"
    jest-mock "^29.6.2"

"@jest/expect-utils@^29.6.2":
  version "29.6.2"
  resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.6.2.tgz#1b97f290d0185d264dd9fdec7567a14a38a90534"
  integrity sha512-6zIhM8go3RV2IG4aIZaZbxwpOzz3ZiM23oxAlkquOIole+G6TrbeXnykxWYlqF7kz2HlBjdKtca20x9atkEQYg==
"@jest/expect-utils@^29.6.2", "@jest/expect-utils@^29.6.4":
  version "29.6.4"
  resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.6.4.tgz#17c7dfe6cec106441f218b0aff4b295f98346679"
  integrity sha512-FEhkJhqtvBwgSpiTrocquJCdXPsyvNKcl/n7A3u7X4pVoF4bswm11c9d4AV+kfq2Gpv/mM8x7E7DsRvH+djkrg==
  dependencies:
    jest-get-type "^29.4.3"
    jest-get-type "^29.6.3"

"@jest/expect@^29.6.2":
  version "29.6.2"


@@ 1567,10 1608,10 @@
    strip-ansi "^6.0.0"
    v8-to-istanbul "^9.0.1"

"@jest/schemas@^29.6.0":
  version "29.6.0"
  resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.0.tgz#0f4cb2c8e3dca80c135507ba5635a4fd755b0040"
  integrity sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==
"@jest/schemas@^29.6.3":
  version "29.6.3"
  resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03"
  integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==
  dependencies:
    "@sinclair/typebox" "^0.27.8"



@@ 1624,12 1665,12 @@
    slash "^3.0.0"
    write-file-atomic "^4.0.2"

"@jest/types@^29.6.1":
  version "29.6.1"
  resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.1.tgz#ae79080278acff0a6af5eb49d063385aaa897bf2"
  integrity sha512-tPKQNMPuXgvdOn2/Lg9HNfUvjYVGolt04Hp03f5hAk878uwOLikN+JzeLY0HcVgKgFl9Hs3EIqpu3WX27XNhnw==
"@jest/types@^29.6.1", "@jest/types@^29.6.3":
  version "29.6.3"
  resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59"
  integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==
  dependencies:
    "@jest/schemas" "^29.6.0"
    "@jest/schemas" "^29.6.3"
    "@types/istanbul-lib-coverage" "^2.0.0"
    "@types/istanbul-reports" "^3.0.0"
    "@types/node" "*"


@@ 1750,40 1791,6 @@
  resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-7.0.7.tgz#54af8d66160a8a7bf7d8f184703d2bf4b3fab914"
  integrity sha512-J2v5Ca7HgejO7diGKiDylaVDQKmbQ5FJih6Oo3hXuBKEuXlcaccJu64lj8MNVLaPVyZx0g4gaOQZQz95QEb/hg==

"@redis/bloom@1.2.0":
  version "1.2.0"
  resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71"
  integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==

"@redis/client@1.5.9":
  version "1.5.9"
  resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.5.9.tgz#c4ee81bbfedb4f1d9c7c5e9859661b9388fb4021"
  integrity sha512-SffgN+P1zdWJWSXBvJeynvEnmnZrYmtKSRW00xl8pOPFOMJjxRR9u0frSxJpPR6Y4V+k54blJjGW7FgxbTI7bQ==
  dependencies:
    cluster-key-slot "1.1.2"
    generic-pool "3.9.0"
    yallist "4.0.0"

"@redis/graph@1.1.0":
  version "1.1.0"
  resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.0.tgz#cc2b82e5141a29ada2cce7d267a6b74baa6dd519"
  integrity sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==

"@redis/json@1.0.4":
  version "1.0.4"
  resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.4.tgz#f372b5f93324e6ffb7f16aadcbcb4e5c3d39bda1"
  integrity sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw==

"@redis/search@1.1.3":
  version "1.1.3"
  resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.3.tgz#b5a6837522ce9028267fe6f50762a8bcfd2e998b"
  integrity sha512-4Dg1JjvCevdiCBTZqjhKkGoC5/BcB7k9j99kdMnaXFXg8x4eyOIVg9487CMv7/BUVkFLZCaIh8ead9mU15DNng==

"@redis/time-series@1.0.5":
  version "1.0.5"
  resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.5.tgz#a6d70ef7a0e71e083ea09b967df0a0ed742bc6ad"
  integrity sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==

"@reduxjs/toolkit@^1.9.5":
  version "1.9.5"
  resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.5.tgz#d3987849c24189ca483baa7aa59386c8e52077c4"


@@ 1984,14 1991,13 @@
    lz-string "^1.5.0"
    pretty-format "^27.0.2"

"@testing-library/jest-dom@^5.16.5":
  version "5.17.0"
  resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz#5e97c8f9a15ccf4656da00fecab505728de81e0c"
  integrity sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==
"@testing-library/jest-dom@^6.0.0":
  version "6.0.0"
  resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.0.0.tgz#d2ba5a3fd13724d5966b3f8cd24d2cedcab4fa76"
  integrity sha512-Ye2R3+/oM27jir8CzYPmuWdavTaKwNZcu0d22L9pO/vnOYE0wmrtpw79TQJa8H6gV8/i7yd+pLaqeLlA0rTMfg==
  dependencies:
    "@adobe/css-tools" "^4.0.1"
    "@babel/runtime" "^7.9.2"
    "@types/testing-library__jest-dom" "^5.9.1"
    aria-query "^5.0.0"
    chalk "^3.0.0"
    css.escape "^1.5.1"


@@ 2187,7 2193,7 @@
  dependencies:
    "@types/istanbul-lib-report" "*"

"@types/jest@*", "@types/jest@^29.5.2":
"@types/jest@^29.5.2":
  version "29.5.3"
  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.3.tgz#7a35dc0044ffb8b56325c6802a4781a626b05777"
  integrity sha512-1Nq7YrO/vJE/FYnqYyw0FS8LdrjExSgIiHyKg7xPpn+yi8Q4huZryKnkJatN1ZRH89Kw2v33/8ZMB7DuZeSLlA==


@@ 2250,9 2256,9 @@
  integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==

"@types/node@*":
  version "20.4.9"
  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.9.tgz#c7164e0f8d3f12dfae336af0b1f7fdec8c6b204f"
  integrity sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==
  version "20.5.7"
  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.7.tgz#4b8ecac87fbefbc92f431d09c30e176fc0a7c377"
  integrity sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==

"@types/node@14 || 16 || 17":
  version "17.0.45"


@@ 2428,9 2434,9 @@
    "@types/react" "*"

"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@>=16.9.11", "@types/react@^18.0.26", "@types/react@^18.2.7":
  version "18.2.20"
  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.20.tgz#1605557a83df5c8a2cc4eeb743b3dfc0eb6aaeb2"
  integrity sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==
  version "18.2.21"
  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9"
  integrity sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==
  dependencies:
    "@types/prop-types" "*"
    "@types/scheduler" "*"


@@ 2497,13 2503,6 @@
  resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310"
  integrity sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==

"@types/testing-library__jest-dom@^5.9.1":
  version "5.14.9"
  resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz#0fb1e6a0278d87b6737db55af5967570b67cb466"
  integrity sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==
  dependencies:
    "@types/jest" "*"

"@types/tough-cookie@*":
  version "4.0.2"
  resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"


@@ 2570,15 2569,15 @@
    "@types/yargs-parser" "*"

"@typescript-eslint/eslint-plugin@^6.0.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.4.0.tgz#53428b616f7d80fe879f45a08f11cc0f0b62cf13"
  integrity sha512-62o2Hmc7Gs3p8SLfbXcipjWAa6qk2wZGChXG2JbBtYpwSRmti/9KHLqfbLs9uDigOexG+3PaQ9G2g3201FWLKg==
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.4.1.tgz#bc0c6f000134b53c304ad0bec4ee4753cd3e89d2"
  integrity sha512-3F5PtBzUW0dYlq77Lcqo13fv+58KDwUib3BddilE8ajPJT+faGgxmI9Sw+I8ZS22BYwoir9ZhNXcLi+S+I2bkw==
  dependencies:
    "@eslint-community/regexpp" "^4.5.1"
    "@typescript-eslint/scope-manager" "6.4.0"
    "@typescript-eslint/type-utils" "6.4.0"
    "@typescript-eslint/utils" "6.4.0"
    "@typescript-eslint/visitor-keys" "6.4.0"
    "@typescript-eslint/scope-manager" "6.4.1"
    "@typescript-eslint/type-utils" "6.4.1"
    "@typescript-eslint/utils" "6.4.1"
    "@typescript-eslint/visitor-keys" "6.4.1"
    debug "^4.3.4"
    graphemer "^1.4.0"
    ignore "^5.2.4"


@@ 2587,31 2586,31 @@
    ts-api-utils "^1.0.1"

"@typescript-eslint/parser@^6.0.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.4.0.tgz#47e7c6e22ff1248e8675d95f488890484de67600"
  integrity sha512-I1Ah1irl033uxjxO9Xql7+biL3YD7w9IU8zF+xlzD/YxY6a4b7DYA08PXUUCbm2sEljwJF6ERFy2kTGAGcNilg==
  dependencies:
    "@typescript-eslint/scope-manager" "6.4.0"
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/typescript-estree" "6.4.0"
    "@typescript-eslint/visitor-keys" "6.4.0"
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.4.1.tgz#85ad550bf4ac4aa227504f1becb828f8e46c44e3"
  integrity sha512-610G6KHymg9V7EqOaNBMtD1GgpAmGROsmfHJPXNLCU9bfIuLrkdOygltK784F6Crboyd5tBFayPB7Sf0McrQwg==
  dependencies:
    "@typescript-eslint/scope-manager" "6.4.1"
    "@typescript-eslint/types" "6.4.1"
    "@typescript-eslint/typescript-estree" "6.4.1"
    "@typescript-eslint/visitor-keys" "6.4.1"
    debug "^4.3.4"

"@typescript-eslint/scope-manager@6.4.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.4.0.tgz#3048e4262ba3eafa4e2e69b08912d9037ec646ae"
  integrity sha512-TUS7vaKkPWDVvl7GDNHFQMsMruD+zhkd3SdVW0d7b+7Zo+bd/hXJQ8nsiUZMi1jloWo6c9qt3B7Sqo+flC1nig==
"@typescript-eslint/scope-manager@6.4.1":
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.4.1.tgz#4b073a30be2dbe603e44e9ae0cff7e1d3ed19278"
  integrity sha512-p/OavqOQfm4/Hdrr7kvacOSFjwQ2rrDVJRPxt/o0TOWdFnjJptnjnZ+sYDR7fi4OimvIuKp+2LCkc+rt9fIW+A==
  dependencies:
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/visitor-keys" "6.4.0"
    "@typescript-eslint/types" "6.4.1"
    "@typescript-eslint/visitor-keys" "6.4.1"

"@typescript-eslint/type-utils@6.4.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.4.0.tgz#c8ac92716ed6a9d5443aa3e342910355b0796ba0"
  integrity sha512-TvqrUFFyGY0cX3WgDHcdl2/mMCWCDv/0thTtx/ODMY1QhEiyFtv/OlLaNIiYLwRpAxAtOLOY9SUf1H3Q3dlwAg==
"@typescript-eslint/type-utils@6.4.1":
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.4.1.tgz#fa21cb13016c8d6f352fe9b2d6c9ab6edc2d1857"
  integrity sha512-7ON8M8NXh73SGZ5XvIqWHjgX2f+vvaOarNliGhjrJnv1vdjG0LVIz+ToYfPirOoBi56jxAKLfsLm40+RvxVVXA==
  dependencies:
    "@typescript-eslint/typescript-estree" "6.4.0"
    "@typescript-eslint/utils" "6.4.0"
    "@typescript-eslint/typescript-estree" "6.4.1"
    "@typescript-eslint/utils" "6.4.1"
    debug "^4.3.4"
    ts-api-utils "^1.0.1"



@@ 2620,10 2619,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@6.4.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.4.0.tgz#5b109a59a805f0d8d375895e42d9e5f0037f66ee"
  integrity sha512-+FV9kVFrS7w78YtzkIsNSoYsnOtrYVnKWSTVXoL1761CsCRv5wpDOINgsXpxD67YCLZtVQekDDyaxfjVWUJmmg==
"@typescript-eslint/types@6.4.1":
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.4.1.tgz#b2c61159f46dda210fed9f117f5d027f65bb5c3b"
  integrity sha512-zAAopbNuYu++ijY1GV2ylCsQsi3B8QvfPHVqhGdDcbx/NK5lkqMnCGU53amAjccSpk+LfeONxwzUhDzArSfZJg==

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


@@ 2638,30 2637,30 @@
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/typescript-estree@6.4.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.4.0.tgz#3c58d20632db93fec3d6ab902acbedf593d37276"
  integrity sha512-iDPJArf/K2sxvjOR6skeUCNgHR/tCQXBsa+ee1/clRKr3olZjZ/dSkXPZjG6YkPtnW6p5D1egeEPMCW6Gn4yLA==
"@typescript-eslint/typescript-estree@6.4.1":
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.4.1.tgz#91ff88101c710adb0f70a317f2f65efa9441da45"
  integrity sha512-xF6Y7SatVE/OyV93h1xGgfOkHr2iXuo8ip0gbfzaKeGGuKiAnzS+HtVhSPx8Www243bwlW8IF7X0/B62SzFftg==
  dependencies:
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/visitor-keys" "6.4.0"
    "@typescript-eslint/types" "6.4.1"
    "@typescript-eslint/visitor-keys" "6.4.1"
    debug "^4.3.4"
    globby "^11.1.0"
    is-glob "^4.0.3"
    semver "^7.5.4"
    ts-api-utils "^1.0.1"

"@typescript-eslint/utils@6.4.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.4.0.tgz#23e996b693603c5924b1fbb733cc73196256baa5"
  integrity sha512-BvvwryBQpECPGo8PwF/y/q+yacg8Hn/2XS+DqL/oRsOPK+RPt29h5Ui5dqOKHDlbXrAeHUTnyG3wZA0KTDxRZw==
"@typescript-eslint/utils@6.4.1":
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.4.1.tgz#81bf62ff0c3119a26c19fab683582e29450717bc"
  integrity sha512-F/6r2RieNeorU0zhqZNv89s9bDZSovv3bZQpUNOmmQK1L80/cV4KEu95YUJWi75u5PhboFoKUJBnZ4FQcoqhDw==
  dependencies:
    "@eslint-community/eslint-utils" "^4.4.0"
    "@types/json-schema" "^7.0.12"
    "@types/semver" "^7.5.0"
    "@typescript-eslint/scope-manager" "6.4.0"
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/typescript-estree" "6.4.0"
    "@typescript-eslint/scope-manager" "6.4.1"
    "@typescript-eslint/types" "6.4.1"
    "@typescript-eslint/typescript-estree" "6.4.1"
    semver "^7.5.4"

"@typescript-eslint/visitor-keys@5.59.0":


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

"@typescript-eslint/visitor-keys@6.4.0":
  version "6.4.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.4.0.tgz#96a426cdb1add28274abd7a34aefe27f8b7d51ef"
  integrity sha512-yJSfyT+uJm+JRDWYRYdCm2i+pmvXJSMtPR9Cq5/XQs4QIgNoLcoRtDdzsLbLsFM/c6um6ohQkg/MLxWvoIndJA==
"@typescript-eslint/visitor-keys@6.4.1":
  version "6.4.1"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.4.1.tgz#e3ccf7b8d42e625946ac5094ed92a405fb4115e0"
  integrity sha512-y/TyRJsbZPkJIZQXrHfdnxVnxyKegnpEvnRGNam7s3TRR2ykGefEWOhaef00/UUN3IZxizS7BTO3svd3lCOJRQ==
  dependencies:
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/types" "6.4.1"
    eslint-visitor-keys "^3.4.1"

"@webassemblyjs/ast@1.9.0":


@@ 2883,16 2882,16 @@ acorn@^6.4.1:
  resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6"
  integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==

acorn@^8.0.4, acorn@^8.8.2:
  version "8.8.2"
  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
  integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==

acorn@^8.1.0, acorn@^8.8.1, acorn@^8.9.0:
acorn@^8.0.4, acorn@^8.1.0, acorn@^8.8.1, acorn@^8.9.0:
  version "8.10.0"
  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
  integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==

acorn@^8.8.2:
  version "8.8.2"
  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
  integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==

agent-base@6:
  version "6.0.2"
  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"


@@ 3319,9 3318,9 @@ axe-core@^4.6.2:
  integrity sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g==

axios@^1.4.0:
  version "1.4.0"
  resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f"
  integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==
  version "1.5.0"
  resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.0.tgz#f02e4af823e2e46a9768cfc74691fdd0517ea267"
  integrity sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==
  dependencies:
    follow-redirects "^1.15.0"
    form-data "^4.0.0"


@@ 3906,7 3905,12 @@ caniuse-lite@^1.0.30001502:
  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001515.tgz#418aefeed9d024cd3129bfae0ccc782d4cb8f12b"
  integrity sha512-eEFDwUOZbE24sb+Ecsx3+OvNETqjWIdabMy52oOkIgcUtAsQifjUG9q4U9dgTHJM2mfk4uEPxc0+xuFdJ629QA==

caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
caniuse-lite@^1.0.30001517:
  version "1.0.30001524"
  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001524.tgz#1e14bce4f43c41a7deaeb5ebfe86664fe8dadb80"
  integrity sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==

caniuse-lite@^1.0.30001520:
  version "1.0.30001520"
  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001520.tgz#62e2b7a1c7b35269594cf296a80bdf8cb9565006"
  integrity sha512-tahF5O9EiiTzwTUqAeFjIZbn4Dnqxzz7ktrgGlMYNLH43Ul26IgTMH/zvL3DG0lZxBYnlT04axvInszUsZULdA==


@@ 4083,7 4087,7 @@ clone-deep@^4.0.1:
    kind-of "^6.0.2"
    shallow-clone "^3.0.0"

cluster-key-slot@1.1.2:
cluster-key-slot@^1.1.0:
  version "1.1.2"
  resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
  integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==


@@ 4305,11 4309,11 @@ copy-descriptor@^0.1.0:
  integrity sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==

core-js-compat@^3.31.0:
  version "3.32.0"
  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.32.0.tgz#f41574b6893ab15ddb0ac1693681bd56c8550a90"
  integrity sha512-7a9a3D1k4UCVKnLhrgALyFcP7YCsLOQIxPd0dKjf/6GuPcgyiGP70ewWdCGrSK7evyhymi0qO4EqCmSJofDeYw==
  version "3.32.1"
  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.32.1.tgz#55f9a7d297c0761a8eb1d31b593e0f5b6ffae964"
  integrity sha512-GSvKDv4wE0bPnQtjklV101juQ85g6H3rm5PDP20mqlS5j0kXF3pP97YvAu5hl+uFHqMictp3b2VxOHljWMAtuA==
  dependencies:
    browserslist "^4.21.9"
    browserslist "^4.21.10"

core-js@^2.5.0:
  version "2.6.12"


@@ 4829,6 4833,11 @@ delegates@^1.0.0:
  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==

denque@^2.1.0:
  version "2.1.0"
  resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
  integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==

depd@2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"


@@ 4884,10 4893,10 @@ detect-passive-events@^2.0.3:
  dependencies:
    detect-it "^4.0.1"

diff-sequences@^29.4.3:
  version "29.4.3"
  resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2"
  integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==
diff-sequences@^29.6.3:
  version "29.6.3"
  resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921"
  integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==

diffie-hellman@^5.0.0:
  version "5.0.3"


@@ 5055,9 5064,9 @@ electron-to-chromium@^1.4.428:
  integrity sha512-/g3UyNDmDd6ebeWapmAoiyy+Sy2HyJ+/X8KyvNeHfKRFfHaA2W8oF5fxD5F3tjBDcjpwo0iek6YNgxNXDBoEtA==

electron-to-chromium@^1.4.477:
  version "1.4.490"
  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.490.tgz#d99286f6e915667fa18ea4554def1aa60eb4d5f1"
  integrity sha512-6s7NVJz+sATdYnIwhdshx/N/9O6rvMxmhVoDSDFdj6iA45gHR8EQje70+RYsF4GeB+k0IeNSBnP7yG9ZXJFr7A==
  version "1.4.505"
  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.505.tgz#00571ade5975b58413f0f56a665b065bfc29cdfc"
  integrity sha512-0A50eL5BCCKdxig2SsCXhpuztnB9PfUgRMojj5tMvt8O54lbwz3t6wNgnpiTRosw5QjlJB7ixhVyeg8daLQwSQ==

elliptic@^6.5.3:
  version "6.5.4"


@@ 5490,14 5499,14 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
  integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==

eslint@^8.41.0:
  version "8.47.0"
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.47.0.tgz#c95f9b935463fb4fad7005e626c7621052e90806"
  integrity sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==
  version "8.48.0"
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.48.0.tgz#bf9998ba520063907ba7bfe4c480dc8be03c2155"
  integrity sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==
  dependencies:
    "@eslint-community/eslint-utils" "^4.2.0"
    "@eslint-community/regexpp" "^4.6.1"
    "@eslint/eslintrc" "^2.1.2"
    "@eslint/js" "^8.47.0"
    "@eslint/js" "8.48.0"
    "@humanwhocodes/config-array" "^0.11.10"
    "@humanwhocodes/module-importer" "^1.0.1"
    "@nodelib/fs.walk" "^1.2.8"


@@ 5701,7 5710,18 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
  dependencies:
    homedir-polyfill "^1.0.1"

expect@^29.0.0, expect@^29.6.2:
expect@^29.0.0:
  version "29.6.4"
  resolved "https://registry.yarnpkg.com/expect/-/expect-29.6.4.tgz#a6e6f66d4613717859b2fe3da98a739437b6f4b8"
  integrity sha512-F2W2UyQ8XYyftHT57dtfg8Ue3X5qLgm2sSug0ivvLRH/VKNRL/pDxg/TH7zVzbQB0tu80clNFy6LU7OS/VSEKA==
  dependencies:
    "@jest/expect-utils" "^29.6.4"
    jest-get-type "^29.6.3"
    jest-matcher-utils "^29.6.4"
    jest-message-util "^29.6.3"
    jest-util "^29.6.3"

expect@^29.6.2:
  version "29.6.2"
  resolved "https://registry.yarnpkg.com/expect/-/expect-29.6.2.tgz#7b08e83eba18ddc4a2cf62b5f2d1918f5cd84521"
  integrity sha512-iAErsLxJ8C+S02QbLAwgSGSezLQK+XXRDt8IuFXFpwCNw2ECmzZSmjKcCaFVp5VRMk+WAvz6h6jokzEzBFZEuA==


@@ 5962,14 5982,15 @@ findup-sync@^3.0.0:
    resolve-dir "^1.0.1"

flat-cache@^3.0.4:
  version "3.0.4"
  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
  integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==
  version "3.1.0"
  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.0.tgz#0e54ab4a1a60fe87e2946b6b00657f1c99e1af3f"
  integrity sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==
  dependencies:
    flatted "^3.1.0"
    flatted "^3.2.7"
    keyv "^4.5.3"
    rimraf "^3.0.2"

flatted@^3.1.0:
flatted@^3.2.7:
  version "3.2.7"
  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
  integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==


@@ 6109,11 6130,6 @@ gauge@^5.0.0:
    strip-ansi "^6.0.1"
    wide-align "^1.1.5"

generic-pool@3.9.0:
  version "3.9.0"
  resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4"
  integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==

gensync@^1.0.0-beta.2:
  version "1.0.0-beta.2"
  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"


@@ 6199,9 6215,9 @@ glob-parent@^6.0.2:
    is-glob "^4.0.3"

glob@^10.2.5, glob@^10.2.6:
  version "10.3.3"
  resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.3.tgz#8360a4ffdd6ed90df84aa8d52f21f452e86a123b"
  integrity sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==
  version "10.3.4"
  resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.4.tgz#c85c9c7ab98669102b6defda76d35c5b1ef9766f"
  integrity sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==
  dependencies:
    foreground-child "^3.1.0"
    jackspeak "^2.0.3"


@@ 6658,9 6674,9 @@ immutable@^3.8.2:
  integrity sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==

immutable@^4.0.0, immutable@^4.0.0-rc.1, immutable@^4.3.0:
  version "4.3.3"
  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.3.tgz#8934ff6826d996a7642c8dc4b46e694dd19561e3"
  integrity sha512-808ZFYMsIRAjLAu5xkKo0TsbY9LBy9H5MazTKIEHerNkg0ymgilGfBPMR/3G7d/ihGmuK2Hw8S1izY2d3kd3wA==
  version "4.3.4"
  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f"
  integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==

import-fresh@^3.2.1:
  version "3.3.0"


@@ 6793,6 6809,21 @@ invariant@^2.2.2, invariant@^2.2.4:
  dependencies:
    loose-envify "^1.0.0"

ioredis@^5.3.2:
  version "5.3.2"
  resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.3.2.tgz#9139f596f62fc9c72d873353ac5395bcf05709f7"
  integrity sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==
  dependencies:
    "@ioredis/commands" "^1.1.1"
    cluster-key-slot "^1.1.0"
    debug "^4.3.4"
    denque "^2.1.0"
    lodash.defaults "^4.2.0"
    lodash.isarguments "^3.1.0"
    redis-errors "^1.2.0"
    redis-parser "^3.0.0"
    standard-as-callback "^2.1.0"

ip-regex@^2.1.0:
  version "2.1.0"
  resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"


@@ 7304,9 7335,9 @@ iterator.prototype@^1.1.0:
    reflect.getprototypeof "^1.0.3"

jackspeak@^2.0.3:
  version "2.2.1"
  resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.2.1.tgz#655e8cf025d872c9c03d3eb63e8f0c024fef16a6"
  integrity sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==
  version "2.3.1"
  resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.1.tgz#ce2effa4c458e053640e61938865a5b5fae98456"
  integrity sha512-4iSY3Bh1Htv+kLhiiZunUhQ+OYXIn0ze3ulq8JeWrFKmhPAJSySV2+kdtRh2pGcCeF0s6oR8Oc+pYZynJj4t8A==
  dependencies:
    "@isaacs/cliui" "^8.0.2"
  optionalDependencies:


@@ 7402,15 7433,15 @@ jest-config@^29.6.2:
    slash "^3.0.0"
    strip-json-comments "^3.1.1"

jest-diff@^29.6.2:
  version "29.6.2"
  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.2.tgz#c36001e5543e82a0805051d3ceac32e6825c1c46"
  integrity sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA==
jest-diff@^29.6.2, jest-diff@^29.6.4:
  version "29.6.4"
  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.4.tgz#85aaa6c92a79ae8cd9a54ebae8d5b6d9a513314a"
  integrity sha512-9F48UxR9e4XOEZvoUXEHSWY4qC4zERJaOfrbBg9JpbJOO43R1vN76REt/aMGZoY6GD5g84nnJiBIVlscegefpw==
  dependencies:
    chalk "^4.0.0"
    diff-sequences "^29.4.3"
    jest-get-type "^29.4.3"
    pretty-format "^29.6.2"
    diff-sequences "^29.6.3"
    jest-get-type "^29.6.3"
    pretty-format "^29.6.3"

jest-docblock@^29.4.3:
  version "29.4.3"


@@ 7456,10 7487,10 @@ jest-environment-node@^29.6.2:
    jest-mock "^29.6.2"
    jest-util "^29.6.2"

jest-get-type@^29.4.3:
  version "29.4.3"
  resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5"
  integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==
jest-get-type@^29.4.3, jest-get-type@^29.6.3:
  version "29.6.3"
  resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1"
  integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==

jest-haste-map@^29.6.2:
  version "29.6.2"


@@ 7488,28 7519,28 @@ jest-leak-detector@^29.6.2:
    jest-get-type "^29.4.3"
    pretty-format "^29.6.2"

jest-matcher-utils@^29.6.2:
  version "29.6.2"
  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.6.2.tgz#39de0be2baca7a64eacb27291f0bd834fea3a535"
  integrity sha512-4LiAk3hSSobtomeIAzFTe+N8kL6z0JtF3n6I4fg29iIW7tt99R7ZcIFW34QkX+DuVrf+CUe6wuVOpm7ZKFJzZQ==
jest-matcher-utils@^29.6.2, jest-matcher-utils@^29.6.4:
  version "29.6.4"
  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.6.4.tgz#327db7ababea49455df3b23e5d6109fe0c709d24"
  integrity sha512-KSzwyzGvK4HcfnserYqJHYi7sZVqdREJ9DMPAKVbS98JsIAvumihaNUbjrWw0St7p9IY7A9UskCW5MYlGmBQFQ==
  dependencies:
    chalk "^4.0.0"
    jest-diff "^29.6.2"
    jest-get-type "^29.4.3"
    pretty-format "^29.6.2"
    jest-diff "^29.6.4"
    jest-get-type "^29.6.3"
    pretty-format "^29.6.3"

jest-message-util@^29.6.2:
  version "29.6.2"
  resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.6.2.tgz#af7adc2209c552f3f5ae31e77cf0a261f23dc2bb"
  integrity sha512-vnIGYEjoPSuRqV8W9t+Wow95SDp6KPX2Uf7EoeG9G99J2OVh7OSwpS4B6J0NfpEIpfkBNHlBZpA2rblEuEFhZQ==
jest-message-util@^29.6.2, jest-message-util@^29.6.3:
  version "29.6.3"
  resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.6.3.tgz#bce16050d86801b165f20cfde34dc01d3cf85fbf"
  integrity sha512-FtzaEEHzjDpQp51HX4UMkPZjy46ati4T5pEMyM6Ik48ztu4T9LQplZ6OsimHx7EuM9dfEh5HJa6D3trEftu3dA==
  dependencies:
    "@babel/code-frame" "^7.12.13"
    "@jest/types" "^29.6.1"
    "@jest/types" "^29.6.3"
    "@types/stack-utils" "^2.0.0"
    chalk "^4.0.0"
    graceful-fs "^4.2.9"
    micromatch "^4.0.4"
    pretty-format "^29.6.2"
    pretty-format "^29.6.3"
    slash "^3.0.0"
    stack-utils "^2.0.3"



@@ 7636,12 7667,12 @@ jest-snapshot@^29.6.2:
    pretty-format "^29.6.2"
    semver "^7.5.3"

jest-util@^29.6.2:
  version "29.6.2"
  resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.6.2.tgz#8a052df8fff2eebe446769fd88814521a517664d"
  integrity sha512-3eX1qb6L88lJNCFlEADKOkjpXJQyZRiavX1INZ4tRnrBVr2COd3RgcTLyUiEXMNBlDU/cgYq6taUS0fExrWW4w==
jest-util@^29.6.2, jest-util@^29.6.3:
  version "29.6.3"
  resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.6.3.tgz#e15c3eac8716440d1ed076f09bc63ace1aebca63"
  integrity sha512-QUjna/xSy4B32fzcKTSz1w7YYzgiHrjjJjevdRf61HYk998R5vVMMNmrHESYZVDS5DSWs+1srPLPKxXPkeSDOA==
  dependencies:
    "@jest/types" "^29.6.1"
    "@jest/types" "^29.6.3"
    "@types/node" "*"
    chalk "^4.0.0"
    ci-info "^3.2.0"


@@ 7815,6 7846,11 @@ jsesc@~0.5.0:
  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
  integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==

json-buffer@3.0.1:
  version "3.0.1"
  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
  integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==

json-parse-better-errors@^1.0.2:
  version "1.0.2"
  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"


@@ 7859,7 7895,7 @@ json5@^1.0.1, json5@^1.0.2:
  dependencies:
    minimist "^1.2.0"

json5@^2.1.2, json5@^2.2.0, json5@^2.2.2:
json5@^2.1.2, json5@^2.2.0, json5@^2.2.3:
  version "2.2.3"
  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
  integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==


@@ 7906,6 7942,13 @@ keycode@^2.1.7:
  resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.1.tgz#09c23b2be0611d26117ea2501c2c391a01f39eff"
  integrity sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==

keyv@^4.5.3:
  version "4.5.3"
  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.3.tgz#00873d2b046df737963157bd04f294ca818c9c25"
  integrity sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==
  dependencies:
    json-buffer "3.0.1"

killable@^1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"


@@ 8056,6 8099,16 @@ lodash.debounce@^4.0.8:
  resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
  integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==

lodash.escape@^4.0.1:
  version "4.0.1"
  resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98"
  integrity sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==

lodash.flatten@^4.4.0:
  version "4.4.0"
  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
  integrity sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==

lodash.get@^4.0:
  version "4.4.2"
  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"


@@ 8066,6 8119,11 @@ lodash.has@^4.0:
  resolved "https://registry.yarnpkg.com/lodash.has/-/lodash.has-4.5.2.tgz#d19f4dc1095058cccbe2b0cdf4ee0fe4aa37c862"
  integrity sha512-rnYUdIo6xRCJnQmbVFEwcxF144erlD+M3YcJUVesflU9paQaE8p+fJDcIQrlMYbxoANFL+AB9hZrzSBBk5PL+g==

lodash.invokemap@^4.6.0:
  version "4.6.0"
  resolved "https://registry.yarnpkg.com/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz#1748cda5d8b0ef8369c4eb3ec54c21feba1f2d62"
  integrity sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==

lodash.isboolean@^3.0.3:
  version "3.0.3"
  resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"


@@ 8091,6 8149,11 @@ lodash.merge@^4.6.2:
  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
  integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==

lodash.pullall@^4.2.0:
  version "4.2.0"
  resolved "https://registry.yarnpkg.com/lodash.pullall/-/lodash.pullall-4.2.0.tgz#9d98b8518b7c965b0fae4099bd9fb7df8bbf38ba"
  integrity sha512-VhqxBKH0ZxPpLhiu68YD1KnHmbhQJQctcipvmFnqIBDYzcIHzf3Zpu0tpeOKtR4x76p9yohc506eGdOjTmyIBg==

lodash.sortby@^4.7.0:
  version "4.7.0"
  resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"


@@ 8106,6 8169,11 @@ lodash.uniq@^4.5.0:
  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
  integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==

lodash.uniqby@^4.7.0:
  version "4.7.0"
  resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302"
  integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==

lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"


@@ 8148,9 8216,9 @@ lru-cache@^6.0.0:
    yallist "^4.0.0"

"lru-cache@^9.1.1 || ^10.0.0":
  version "10.0.0"
  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.0.tgz#b9e2a6a72a129d81ab317202d93c7691df727e61"
  integrity sha512-svTf/fzsKHffP42sujkO/Rjs37BCIsQVRCeNYIm9WN8rgT7ffoUnRtZCqU+6BqcSBdv8gwJeTz8knJpgACeQMw==
  version "10.0.1"
  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a"
  integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==

lz-string@^1.5.0:
  version "1.5.0"


@@ 8482,9 8550,9 @@ minipass@^5.0.0:
  integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==

"minipass@^5.0.0 || ^6.0.2 || ^7.0.0":
  version "7.0.2"
  resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.2.tgz#58a82b7d81c7010da5bd4b2c0c85ac4b4ec5131e"
  integrity sha512-eL79dXrE1q9dBbDCLg7xfn/vl7MS4F1gvJAgjJrQli/jbQWdUttuVawphqpffoIYfRdq78LHx6GP4bU/EQ2ATA==
  version "7.0.3"
  resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.3.tgz#05ea638da44e475037ed94d1c7efcc76a25e1974"
  integrity sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg==

minizlib@^2.1.1:
  version "2.1.2"


@@ 9581,9 9649,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
  integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==

postcss@^8.2.15, postcss@^8.4.24, postcss@^8.4.25:
  version "8.4.28"
  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.28.tgz#c6cc681ed00109072816e1557f889ef51cf950a5"
  integrity sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==
  version "8.4.29"
  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.29.tgz#33bc121cf3b3688d4ddef50be869b2a54185a1dd"
  integrity sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==
  dependencies:
    nanoid "^3.3.6"
    picocolors "^1.0.0"


@@ 9669,12 9737,12 @@ pretty-format@^27.0.2:
    ansi-styles "^5.0.0"
    react-is "^17.0.1"

pretty-format@^29.0.0, pretty-format@^29.6.2:
  version "29.6.2"
  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47"
  integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==
pretty-format@^29.0.0, pretty-format@^29.6.2, pretty-format@^29.6.3:
  version "29.6.3"
  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.3.tgz#d432bb4f1ca6f9463410c3fb25a0ba88e594ace7"
  integrity sha512-ZsBgjVhFAj5KeK+nHfF1305/By3lechHQSMWCTl8iHSbfOm2TN5nHEtFc/+W7fAyUeCs2n5iow72gld4gW0xDw==
  dependencies:
    "@jest/schemas" "^29.6.0"
    "@jest/schemas" "^29.6.3"
    ansi-styles "^5.0.0"
    react-is "^18.0.0"



@@ 10216,17 10284,17 @@ redent@^4.0.0:
    indent-string "^5.0.0"
    strip-indent "^4.0.0"

redis@^4.6.5:
  version "4.6.8"
  resolved "https://registry.yarnpkg.com/redis/-/redis-4.6.8.tgz#54c5992e8a5ba512506fe9f53142cadc405547e7"
  integrity sha512-S7qNkPUYrsofQ0ztWlTHSaK0Qqfl1y+WMIxrzeAGNG+9iUZB4HGeBgkHxE6uJJ6iXrkvLd1RVJ2nvu6H1sAzfQ==
redis-errors@^1.0.0, redis-errors@^1.2.0:
  version "1.2.0"
  resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
  integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==

redis-parser@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
  integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
  dependencies:
    "@redis/bloom" "1.2.0"
    "@redis/client" "1.5.9"
    "@redis/graph" "1.1.0"
    "@redis/json" "1.0.4"
    "@redis/search" "1.1.3"
    "@redis/time-series" "1.0.5"
    redis-errors "^1.0.0"

redux-immutable@^4.0.0:
  version "4.0.0"


@@ 10861,14 10929,14 @@ signal-exit@^4.0.1:
  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
  integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==

sirv@^1.0.7:
  version "1.0.19"
  resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49"
  integrity sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==
sirv@^2.0.3:
  version "2.0.3"
  resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.3.tgz#ca5868b87205a74bef62a469ed0296abceccd446"
  integrity sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==
  dependencies:
    "@polka/url" "^1.0.0-next.20"
    mrmime "^1.0.0"
    totalist "^1.0.0"
    totalist "^3.0.0"

sisteransi@^1.0.5:
  version "1.0.5"


@@ 11144,6 11212,11 @@ stacktrace-js@^2.0.2:
    stack-generator "^2.0.5"
    stacktrace-gps "^3.0.4"

standard-as-callback@^2.1.0:
  version "2.1.0"
  resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
  integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==

static-extend@^0.1.1:
  version "0.1.2"
  resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"


@@ 11301,7 11374,6 @@ stringz@^2.1.0:
    char-regex "^1.0.2"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
  name strip-ansi-cjs
  version "6.0.1"
  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==


@@ 11788,10 11860,10 @@ toidentifier@1.0.1:
  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==

totalist@^1.0.0:
  version "1.1.0"
  resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"
  integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==
totalist@^3.0.0:
  version "3.0.1"
  resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
  integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==

tough-cookie@^4.1.2:
  version "4.1.3"


@@ 12344,19 12416,26 @@ webpack-assets-manifest@^4.0.6:
    webpack-sources "^1.0"

webpack-bundle-analyzer@^4.8.0:
  version "4.9.0"
  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.9.0.tgz#fc093c4ab174fd3dcbd1c30b763f56d10141209d"
  integrity sha512-+bXGmO1LyiNx0i9enBu3H8mv42sj/BJWhZNFwjz92tVnBa9J3JMGo2an2IXlEleoDOPn/Hofl5hr/xCpObUDtw==
  version "4.9.1"
  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.9.1.tgz#d00bbf3f17500c10985084f22f1a2bf45cb2f09d"
  integrity sha512-jnd6EoYrf9yMxCyYDPj8eutJvtjQNp8PHmni/e/ulydHBWhT5J3menXt3HEkScsu9YqMAcG4CfFjs3rj5pVU1w==
  dependencies:
    "@discoveryjs/json-ext" "0.5.7"
    acorn "^8.0.4"
    acorn-walk "^8.0.0"
    chalk "^4.1.0"
    commander "^7.2.0"
    escape-string-regexp "^4.0.0"
    gzip-size "^6.0.0"
    lodash "^4.17.20"
    is-plain-object "^5.0.0"
    lodash.debounce "^4.0.8"
    lodash.escape "^4.0.1"
    lodash.flatten "^4.4.0"
    lodash.invokemap "^4.6.0"
    lodash.pullall "^4.2.0"
    lodash.uniqby "^4.7.0"
    opener "^1.5.2"
    sirv "^1.0.7"
    picocolors "^1.0.0"
    sirv "^2.0.3"
    ws "^7.3.1"

webpack-cli@^3.3.12:


@@ 12893,16 12972,16 @@ y18n@^5.0.5:
  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
  integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==

yallist@4.0.0, yallist@^4.0.0:
  version "4.0.0"
  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==

yallist@^3.0.2:
  version "3.1.1"
  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==

yallist@^4.0.0:
  version "4.0.0"
  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==

yaml@^1.10.0:
  version "1.10.2"
  resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"