~cytrogen/masto-fe

d847c2060e54560ef37a2ae62fd82c2874a3c144 — Claire 2 years ago 8eb0946 + 685270f
Merge pull request #2383 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes
78 files changed, 1699 insertions(+), 782 deletions(-)

M .devcontainer/devcontainer.json
M .env.vagrant
M .github/workflows/build-nightly.yml
M .rubocop_todo.yml
M CHANGELOG.md
M Gemfile
M Gemfile.lock
M SECURITY.md
M Vagrantfile
M app/chewy/instances_index.rb
A app/controllers/api/v1/profile/avatars_controller.rb
A app/controllers/api/v1/profile/headers_controller.rb
M app/helpers/context_helper.rb
M app/helpers/languages_helper.rb
M app/javascript/core/settings.js
M app/javascript/flavours/glitch/components/column.jsx
M app/javascript/flavours/glitch/features/explore/results.jsx
M app/javascript/flavours/glitch/features/status/index.jsx
M app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
M app/javascript/flavours/glitch/features/ui/components/report_modal.jsx
M app/javascript/flavours/glitch/packs/public.jsx
M app/javascript/flavours/glitch/styles/components/modal.scss
M app/javascript/flavours/glitch/styles/components/search.scss
M app/javascript/flavours/glitch/styles/forms.scss
A app/javascript/mastodon/components/__tests__/hashtag_bar.tsx
M app/javascript/mastodon/components/column.jsx
D app/javascript/mastodon/components/hashtag_bar.jsx
A app/javascript/mastodon/components/hashtag_bar.tsx
M app/javascript/mastodon/components/status.jsx
M app/javascript/mastodon/components/status_content.jsx
M app/javascript/mastodon/features/explore/results.jsx
M app/javascript/mastodon/features/status/components/detailed_status.jsx
M app/javascript/mastodon/features/status/index.jsx
M app/javascript/mastodon/features/ui/components/modal_root.jsx
M app/javascript/mastodon/features/ui/components/report_modal.jsx
M app/javascript/packs/public.jsx
M app/javascript/styles/mastodon/components.scss
M app/javascript/styles/mastodon/forms.scss
M app/lib/activitypub/activity/create.rb
M app/lib/activitypub/activity/update.rb
M app/lib/admin/system_check/elasticsearch_check.rb
M app/lib/importer/base_importer.rb
M app/models/account.rb
M app/models/follow_recommendation.rb
M app/models/public_feed.rb
M app/serializers/activitypub/actor_serializer.rb
M app/services/account_search_service.rb
M app/services/activitypub/process_account_service.rb
M app/views/settings/preferences/notifications/show.html.haml
M app/views/settings/profiles/show.html.haml
A app/workers/account_refresh_worker.rb
M config/locales/en.yml
M config/routes/api.rb
M config/webpack/shared.js
A db/migrate/20230818141056_create_global_follow_recommendations.rb
A db/post_migrate/20230818142253_drop_follow_recommendations.rb
M db/schema.rb
A db/views/global_follow_recommendations_v01.sql
M lib/http_extensions.rb
M lib/mastodon/version.rb
M lib/paperclip/transcoder.rb
M lib/redis/namespace_extensions.rb
M package.json
M spec/controllers/api/v1/instances/translation_languages_controller_spec.rb
M spec/controllers/api/v1/statuses/translations_controller_spec.rb
M spec/helpers/admin/filter_helper_spec.rb
M spec/helpers/application_helper_spec.rb
M spec/helpers/home_helper_spec.rb
M spec/lib/admin/system_check/elasticsearch_check_spec.rb
M spec/models/report_filter_spec.rb
A spec/requests/api/v1/profiles_spec.rb
M spec/requests/api/v1/timelines/public_spec.rb
M spec/services/suspend_account_service_spec.rb
M spec/services/translate_status_service_spec.rb
M spec/services/unsuspend_account_service_spec.rb
M spec/views/statuses/show.html.haml_spec.rb
M streaming/index.js
M yarn.lock
M .devcontainer/devcontainer.json => .devcontainer/devcontainer.json +7 -9
@@ 1,31 1,29 @@
// For more details, see https://aka.ms/devcontainer.json.
{
  "name": "Mastodon",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

  // Features to add to the dev container. More info: https://containers.dev/features.
  "features": {
    "ghcr.io/devcontainers/features/sshd:1": {}
  },

  // Use 'forwardPorts' to make a list of ports inside the container available locally.
  // This can be used to network with other containers or the host.
  "runServices": ["app", "db", "redis"],

  "forwardPorts": [3000, 4000],

  // Use 'postCreateCommand' to run commands after the container is created.
  "containerEnv": {
    "ES_ENABLED": "",
    "LIBRE_TRANSLATE_ENDPOINT": ""
  },

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

  // Configure tool-specific properties.
  "customizations": {
    // Configure properties specific to VS Code.
    "vscode": {
      // Set *default* container specific settings.json values on container create.
      "settings": {},
      // Add the IDs of extensions you want installed when the container is created.
      "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"]
    }
  }

M .env.vagrant => .env.vagrant +4 -0
@@ 2,3 2,7 @@ VAGRANT=true
LOCAL_DOMAIN=mastodon.local
BIND=0.0.0.0
DB_HOST=/var/run/postgresql/

ES_ENABLED=true
ES_HOST=localhost
ES_PORT=9200
\ No newline at end of file

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



@@ 28,8 28,8 @@ 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 }}
      # The `-` is important here, result will be v4.1.2-nightly.2022-03-05
      version_suffix: -${{ needs.compute-suffix.outputs.suffix }}
      labels: |
        org.opencontainers.image.description=Nightly build image used for testing purposes
      flavor: |

M .rubocop_todo.yml => .rubocop_todo.yml +24 -32
@@ 1,6 1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.54.2.
# using RuboCop version 1.56.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new


@@ 61,38 61,8 @@ Lint/EmptyBlock:
    - 'spec/fabricators/access_token_fabricator.rb'
    - 'spec/fabricators/conversation_fabricator.rb'
    - 'spec/fabricators/system_key_fabricator.rb'
    - 'spec/helpers/admin/action_logs_helper_spec.rb'
    - 'spec/lib/activitypub/adapter_spec.rb'
    - 'spec/models/account_alias_spec.rb'
    - 'spec/models/account_deletion_request_spec.rb'
    - 'spec/models/account_moderation_note_spec.rb'
    - 'spec/models/announcement_mute_spec.rb'
    - 'spec/models/announcement_reaction_spec.rb'
    - 'spec/models/announcement_spec.rb'
    - 'spec/models/backup_spec.rb'
    - 'spec/models/conversation_mute_spec.rb'
    - 'spec/models/custom_filter_keyword_spec.rb'
    - 'spec/models/custom_filter_spec.rb'
    - 'spec/models/device_spec.rb'
    - 'spec/models/encrypted_message_spec.rb'
    - 'spec/models/featured_tag_spec.rb'
    - 'spec/models/follow_recommendation_suppression_spec.rb'
    - 'spec/models/list_account_spec.rb'
    - 'spec/models/list_spec.rb'
    - 'spec/models/login_activity_spec.rb'
    - 'spec/models/mute_spec.rb'
    - 'spec/models/preview_card_spec.rb'
    - 'spec/models/preview_card_trend_spec.rb'
    - 'spec/models/relay_spec.rb'
    - 'spec/models/scheduled_status_spec.rb'
    - 'spec/models/status_stat_spec.rb'
    - 'spec/models/status_trend_spec.rb'
    - 'spec/models/system_key_spec.rb'
    - 'spec/models/tag_follow_spec.rb'
    - 'spec/models/unavailable_domain_spec.rb'
    - 'spec/models/user_invite_request_spec.rb'
    - 'spec/models/user_role_spec.rb'
    - 'spec/models/web/setting_spec.rb'

Lint/NonLocalExitFromIterator:
  Exclude:


@@ 135,7 105,7 @@ Lint/UselessAssignment:

# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize:
  Max: 146
  Max: 144
  Exclude:
    - 'app/serializers/initial_state_serializer.rb'



@@ 166,6 136,19 @@ Naming/VariableNumber:
    - 'spec/models/domain_block_spec.rb'
    - 'spec/models/user_spec.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: SafeMultiline.
Performance/DeletePrefix:
  Exclude:
    - 'app/models/featured_tag.rb'

Performance/MapMethodChain:
  Exclude:
    - 'app/models/feed.rb'
    - 'lib/mastodon/cli/maintenance.rb'
    - 'spec/services/bulk_import_service_spec.rb'
    - 'spec/services/import_service_spec.rb'

RSpec/AnyInstance:
  Exclude:
    - 'spec/controllers/activitypub/inboxes_controller_spec.rb'


@@ 765,6 748,15 @@ Style/RedundantFetchBlock:
    - 'config/initializers/paperclip.rb'
    - 'config/puma.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowMultipleReturnValues.
Style/RedundantReturn:
  Exclude:
    - 'app/controllers/api/v1/directories_controller.rb'
    - 'app/controllers/auth/confirmations_controller.rb'
    - 'app/lib/ostatus/tag_manager.rb'
    - 'app/models/form/import.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
# AllowedMethods: present?, blank?, presence, try, try!

M CHANGELOG.md => CHANGELOG.md +26 -7
@@ 8,6 8,9 @@ The following changelog entries focus on changes visible to users, administrator

### Added

- **Add “Privacy and reach” tab in profile settings** ([Gargron](https://github.com/mastodon/mastodon/pull/26484), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26508))
  This reorganized scattered privacy and reach settings to a single place, as well as improve their wording.
- **Add display of out-of-band hashtags in the web interface** ([Gargron](https://github.com/mastodon/mastodon/pull/26492), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26497), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26506), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26525))
- **Add role badges to the web interface** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25649), [Gargron](https://github.com/mastodon/mastodon/pull/26281))
- **Add ability to pick domains to forward reports to using the `forward_to_domains` parameter in `POST /api/v1/reports`** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25866))
  The `forward_to_domains` REST API parameter is a list of strings. If it is empty or omitted, the previous behavior is maintained.


@@ 23,8 26,18 @@ The following changelog entries focus on changes visible to users, administrator
- **Add optional hCaptcha support** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25019), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25057), [Gargron](https://github.com/mastodon/mastodon/pull/25395), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26388))
- **Add lines to threads in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24549), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24677), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24696), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24711), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24713), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24715), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24800), [teeerevor](https://github.com/mastodon/mastodon/pull/25706), [renchap](https://github.com/mastodon/mastodon/pull/25807))
- **Add new onboarding flow to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24619), [Gargron](https://github.com/mastodon/mastodon/pull/24646), [Gargron](https://github.com/mastodon/mastodon/pull/24705), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24872), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24883), [Gargron](https://github.com/mastodon/mastodon/pull/24954), [stevenjlm](https://github.com/mastodon/mastodon/pull/24959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25010), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25275), [Gargron](https://github.com/mastodon/mastodon/pull/25559), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25561))
- **Add `S3_DISABLE_CHECKSUM_MODE` environment variable for compatibility with some S3-compatible providers** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26435))
- **Add auto-refresh of accounts we get new messages/edits of** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26510))
- **Add Elasticsearch cluster health check and indexes mismatch check to dashboard** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26448))
- Add support for `indexable` attribute on remote actors ([Gargron](https://github.com/mastodon/mastodon/pull/26485))
- Add `DELETE /api/v1/profile/avatar` and `DELETE /api/v1/profile/header` to the REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25124), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26573))
- Add `ES_PRESET` option to customize numbers of shards and replicas ([Gargron](https://github.com/mastodon/mastodon/pull/26483), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26489))
  This can have a value of `single_node_cluster` (default), `small_cluster` (uses one replica) or `large_cluster` (uses one replica and a higher number of shards).
- Add missing `instances` option to `tootctl search deploy` ([tribela](https://github.com/mastodon/mastodon/pull/26461))
- Add `CACHE_BUSTER_HTTP_METHOD` environment variable ([renchap](https://github.com/mastodon/mastodon/pull/26528), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26542))
- Add support for `DB_PASS` when using `DATABASE_URL` ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26295))
- Add `GET /api/v1/instance/languages` to REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24443))
- Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384))
- Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26447))
- Add client-side timeout on resend confirmation button ([Gargron](https://github.com/mastodon/mastodon/pull/26300))
- Add published date and author to news on the explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26155))
- Add `lang` attribute to various UI components ([c960657](https://github.com/mastodon/mastodon/pull/23869), [c960657](https://github.com/mastodon/mastodon/pull/23891), [c960657](https://github.com/mastodon/mastodon/pull/26111), [c960657](https://github.com/mastodon/mastodon/pull/26149))


@@ 43,7 56,7 @@ The following changelog entries focus on changes visible to users, administrator
- Add unsubscribe link and headers to e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/25378), [c960657](https://github.com/mastodon/mastodon/pull/26085))
- Add logging of websocket send errors ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25280))
- Add time zone preference ([Gargron](https://github.com/mastodon/mastodon/pull/25342), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26025))
- Add `legal` as report category ([Gargron](https://github.com/mastodon/mastodon/pull/23941), [renchap](https://github.com/mastodon/mastodon/pull/25400))
- Add `legal` as report category ([Gargron](https://github.com/mastodon/mastodon/pull/23941), [renchap](https://github.com/mastodon/mastodon/pull/25400), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26509))
- Add `data-nosnippet` so Google doesn't use trending posts in snippets for `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25279))
- Add card with who invited you to join when displaying rules on sign-up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23475))
- Add missing primary keys to `accounts_tags` and `statuses_tags` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25210))


@@ 80,11 93,12 @@ The following changelog entries focus on changes visible to users, administrator

### Changed

- **Change hashtags to be displayed separately when they are the last line of a post** ([renchap](https://github.com/mastodon/mastodon/pull/26499))
- **Change reblogs to be excluded from "Posts and replies" tab in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26302))
- **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267))
- **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267), [mgmn](https://github.com/mastodon/mastodon/pull/26459))
- **Change design of link previews in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26136), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26151), [Gargron](https://github.com/mastodon/mastodon/pull/26153), [Gargron](https://github.com/mastodon/mastodon/pull/26250), [Gargron](https://github.com/mastodon/mastodon/pull/26287), [Gargron](https://github.com/mastodon/mastodon/pull/26286), [c960657](https://github.com/mastodon/mastodon/pull/26184))
- **Change "direct message" nomenclature to "private mention" in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24248))
- **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))
- **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))


@@ 97,7 111,9 @@ The following changelog entries focus on changes visible to users, administrator
- **Change replica support to native Rails adapter** ([krainboltgreene](https://github.com/mastodon/mastodon/pull/25693), [Gargron](https://github.com/mastodon/mastodon/pull/25849), [Gargron](https://github.com/mastodon/mastodon/pull/25874), [Gargron](https://github.com/mastodon/mastodon/pull/25851), [Gargron](https://github.com/mastodon/mastodon/pull/25977), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26074), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26386))
  This is a breaking change, dropping `makara` support, and requiring you to update your database configuration if you are using replicas.
  To tell Mastodon to use a read replica, you can either set the `REPLICA_DB_NAME` environment variable (along with `REPLICA_DB_USER`, `REPLICA_DB_PASS`, `REPLICA_DB_HOST`, and `REPLICA_DB_PORT`, if they differ from the primary database), or the `REPLICA_DATABASE_URL` environment variable if your configuration is based on `DATABASE_URL`.
- Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362))
- Change follow recommendation materialized view to be faster in most cases ([renchap, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26545))
- Change `robots.txt` to block GPTBot ([Foritus](https://github.com/mastodon/mastodon/pull/26396))
- Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26416))
- Change streaming `/metrics` to include additional metrics ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26299))
- Change indexing frequency from 5 minutes to 1 minute, add locks to schedulers ([Gargron](https://github.com/mastodon/mastodon/pull/26304))
- Change column link to add a better keyboard focus indicator ([teeerevor](https://github.com/mastodon/mastodon/pull/26278))


@@ 114,7 130,7 @@ The following changelog entries focus on changes visible to users, administrator
- Change header backgrounds to use fewer different colors in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25577))
- Change files to be deleted in batches instead of one-by-one ([Gargron](https://github.com/mastodon/mastodon/pull/23302), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25586), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25587))
- Change emoji picker icon ([iparr](https://github.com/mastodon/mastodon/pull/25479))
- Change edit profile page ([Gargron](https://github.com/mastodon/mastodon/pull/25413))
- Change edit profile page ([Gargron](https://github.com/mastodon/mastodon/pull/25413), [c960657](https://github.com/mastodon/mastodon/pull/26538))
- Change "bot" label to "automated" ([Gargron](https://github.com/mastodon/mastodon/pull/25356))
- Change design of dropdowns in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25107))
- Change wording of “Content cache retention period” setting to highlight destructive implications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23261))


@@ 172,6 188,9 @@ The following changelog entries focus on changes visible to users, administrator
- **Fix being unable to load past a full page of filtered posts in Home timeline** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24930))
- **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 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))
- Fix light theme select option for hashtags ([teeerevor](https://github.com/mastodon/mastodon/pull/26311))
- Fix AVIF attachments ([c960657](https://github.com/mastodon/mastodon/pull/26264))


@@ 189,7 208,7 @@ The following changelog entries focus on changes visible to users, administrator
- Fix for "follows you" indicator in light web UI not readable ([vmstan](https://github.com/mastodon/mastodon/pull/25993))
- Fix incorrect line break between icon and number of reposts & favourites ([edent](https://github.com/mastodon/mastodon/pull/26004))
- Fix sounds not being loaded from assets host ([Signez](https://github.com/mastodon/mastodon/pull/25931))
- Fix buttons showing inconsistent styles ([teeerevor](https://github.com/mastodon/mastodon/pull/25903), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25965), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26341))
- Fix buttons showing inconsistent styles ([teeerevor](https://github.com/mastodon/mastodon/pull/25903), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25965), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26482))
- Fix trend calculation working on too many items at a time ([Gargron](https://github.com/mastodon/mastodon/pull/25835))
- Fix dropdowns being disabled for logged out users in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25964))
- Fix explore page being inaccessible when opted-out of trends in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25716))

M Gemfile => Gemfile +1 -1
@@ 110,7 110,7 @@ group :test do
  gem 'fuubar', '~> 2.5'

  # Extra RSpec extenion methods and helpers for sidekiq
  gem 'rspec-sidekiq', '~> 3.1'
  gem 'rspec-sidekiq', '~> 4.0'

  # Browser integration testing
  gem 'capybara', '~> 3.39'

M Gemfile.lock => Gemfile.lock +68 -64
@@ 39,47 39,47 @@ GIT
GEM
  remote: https://rubygems.org/
  specs:
    actioncable (7.0.7)
      actionpack (= 7.0.7)
      activesupport (= 7.0.7)
    actioncable (7.0.7.2)
      actionpack (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      nio4r (~> 2.0)
      websocket-driver (>= 0.6.1)
    actionmailbox (7.0.7)
      actionpack (= 7.0.7)
      activejob (= 7.0.7)
      activerecord (= 7.0.7)
      activestorage (= 7.0.7)
      activesupport (= 7.0.7)
    actionmailbox (7.0.7.2)
      actionpack (= 7.0.7.2)
      activejob (= 7.0.7.2)
      activerecord (= 7.0.7.2)
      activestorage (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      mail (>= 2.7.1)
      net-imap
      net-pop
      net-smtp
    actionmailer (7.0.7)
      actionpack (= 7.0.7)
      actionview (= 7.0.7)
      activejob (= 7.0.7)
      activesupport (= 7.0.7)
    actionmailer (7.0.7.2)
      actionpack (= 7.0.7.2)
      actionview (= 7.0.7.2)
      activejob (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      mail (~> 2.5, >= 2.5.4)
      net-imap
      net-pop
      net-smtp
      rails-dom-testing (~> 2.0)
    actionpack (7.0.7)
      actionview (= 7.0.7)
      activesupport (= 7.0.7)
    actionpack (7.0.7.2)
      actionview (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      rack (~> 2.0, >= 2.2.4)
      rack-test (>= 0.6.3)
      rails-dom-testing (~> 2.0)
      rails-html-sanitizer (~> 1.0, >= 1.2.0)
    actiontext (7.0.7)
      actionpack (= 7.0.7)
      activerecord (= 7.0.7)
      activestorage (= 7.0.7)
      activesupport (= 7.0.7)
    actiontext (7.0.7.2)
      actionpack (= 7.0.7.2)
      activerecord (= 7.0.7.2)
      activestorage (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      globalid (>= 0.6.0)
      nokogiri (>= 1.8.5)
    actionview (7.0.7)
      activesupport (= 7.0.7)
    actionview (7.0.7.2)
      activesupport (= 7.0.7.2)
      builder (~> 3.1)
      erubi (~> 1.4)
      rails-dom-testing (~> 2.0)


@@ 89,22 89,22 @@ GEM
      activemodel (>= 4.1, < 7.1)
      case_transform (>= 0.2)
      jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
    activejob (7.0.7)
      activesupport (= 7.0.7)
    activejob (7.0.7.2)
      activesupport (= 7.0.7.2)
      globalid (>= 0.3.6)
    activemodel (7.0.7)
      activesupport (= 7.0.7)
    activerecord (7.0.7)
      activemodel (= 7.0.7)
      activesupport (= 7.0.7)
    activestorage (7.0.7)
      actionpack (= 7.0.7)
      activejob (= 7.0.7)
      activerecord (= 7.0.7)
      activesupport (= 7.0.7)
    activemodel (7.0.7.2)
      activesupport (= 7.0.7.2)
    activerecord (7.0.7.2)
      activemodel (= 7.0.7.2)
      activesupport (= 7.0.7.2)
    activestorage (7.0.7.2)
      actionpack (= 7.0.7.2)
      activejob (= 7.0.7.2)
      activerecord (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      marcel (~> 1.0)
      mini_mime (>= 1.1.0)
    activesupport (7.0.7)
    activesupport (7.0.7.2)
      concurrent-ruby (~> 1.0, >= 1.0.2)
      i18n (>= 1.6, < 2)
      minitest (>= 5.1)


@@ 147,6 147,7 @@ GEM
      faraday_middleware (~> 1.0, >= 1.0.0.rc1)
      net-http-persistent (~> 4.0)
      nokogiri (~> 1, >= 1.10.8)
    base64 (0.1.1)
    bcrypt (3.1.18)
    better_errors (2.10.1)
      erubi (>= 1.0.0)


@@ 451,7 452,7 @@ GEM
      hashie (~> 5.0)
    memory_profiler (1.0.1)
    method_source (1.0.0)
    mime-types (3.5.0)
    mime-types (3.5.1)
      mime-types-data (~> 3.2015)
    mime-types-data (3.2023.0808)
    mini_mime (1.1.5)


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


@@ 555,20 556,20 @@ GEM
      rack
    rack-test (2.1.0)
      rack (>= 1.3)
    rails (7.0.7)
      actioncable (= 7.0.7)
      actionmailbox (= 7.0.7)
      actionmailer (= 7.0.7)
      actionpack (= 7.0.7)
      actiontext (= 7.0.7)
      actionview (= 7.0.7)
      activejob (= 7.0.7)
      activemodel (= 7.0.7)
      activerecord (= 7.0.7)
      activestorage (= 7.0.7)
      activesupport (= 7.0.7)
    rails (7.0.7.2)
      actioncable (= 7.0.7.2)
      actionmailbox (= 7.0.7.2)
      actionmailer (= 7.0.7.2)
      actionpack (= 7.0.7.2)
      actiontext (= 7.0.7.2)
      actionview (= 7.0.7.2)
      activejob (= 7.0.7.2)
      activemodel (= 7.0.7.2)
      activerecord (= 7.0.7.2)
      activestorage (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      bundler (>= 1.15.0)
      railties (= 7.0.7)
      railties (= 7.0.7.2)
    rails-controller-testing (1.0.5)
      actionpack (>= 5.0.1.rc1)
      actionview (>= 5.0.1.rc1)


@@ 583,9 584,9 @@ GEM
    rails-i18n (7.0.7)
      i18n (>= 0.7, < 2)
      railties (>= 6.0.0, < 8)
    railties (7.0.7)
      actionpack (= 7.0.7)
      activesupport (= 7.0.7)
    railties (7.0.7.2)
      actionpack (= 7.0.7.2)
      activesupport (= 7.0.7.2)
      method_source
      rake (>= 12.2)
      thor (~> 1.0)


@@ 632,12 633,15 @@ GEM
      rspec-expectations (~> 3.12)
      rspec-mocks (~> 3.12)
      rspec-support (~> 3.12)
    rspec-sidekiq (3.1.0)
      rspec-core (~> 3.0, >= 3.0.0)
      sidekiq (>= 2.4.0)
    rspec-support (3.12.0)
    rspec-sidekiq (4.0.1)
      rspec-core (~> 3.0)
      rspec-expectations (~> 3.0)
      rspec-mocks (~> 3.0)
      sidekiq (>= 5, < 8)
    rspec-support (3.12.1)
    rspec_chunked (0.6)
    rubocop (1.54.2)
    rubocop (1.56.1)
      base64 (~> 0.1.1)
      json (~> 2.3)
      language_server-protocol (>= 3.17.0)
      parallel (~> 1.10)


@@ 645,7 649,7 @@ GEM
      rainbow (>= 2.2.2, < 4.0)
      regexp_parser (>= 1.8, < 3.0)
      rexml (>= 3.2.5, < 4.0)
      rubocop-ast (>= 1.28.0, < 2.0)
      rubocop-ast (>= 1.28.1, < 2.0)
      ruby-progressbar (~> 1.7)
      unicode-display_width (>= 2.4.0, < 3.0)
    rubocop-ast (1.29.0)


@@ 654,14 658,14 @@ GEM
      rubocop (~> 1.41)
    rubocop-factory_bot (2.23.1)
      rubocop (~> 1.33)
    rubocop-performance (1.18.0)
    rubocop-performance (1.19.0)
      rubocop (>= 1.7.0, < 2.0)
      rubocop-ast (>= 0.4.0)
    rubocop-rails (2.20.2)
      activesupport (>= 4.2.0)
      rack (>= 1.1)
      rubocop (>= 1.33.0, < 2.0)
    rubocop-rspec (2.22.0)
    rubocop-rspec (2.23.2)
      rubocop (~> 1.33)
      rubocop-capybara (~> 2.17)
      rubocop-factory_bot (~> 2.22)


@@ 909,7 913,7 @@ DEPENDENCIES
  redis-namespace (~> 1.10)
  rqrcode (~> 2.2)
  rspec-rails (~> 6.0)
  rspec-sidekiq (~> 3.1)
  rspec-sidekiq (~> 4.0)
  rspec_chunked (~> 0.6)
  rubocop
  rubocop-capybara

M SECURITY.md => SECURITY.md +5 -2
@@ 1,8 1,11 @@
# Security Policy

If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can reach us at <security@joinmastodon.org>.
If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can either:

You should _not_ report such issues on GitHub or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk.
- open a [Github security issue on the Mastodon project](https://github.com/mastodon/mastodon/security/advisories/new)
- reach us at <security@joinmastodon.org>

You should _not_ report such issues on public GitHub issues or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk.

## Scope


M Vagrantfile => Vagrantfile +39 -4
@@ 60,6 60,37 @@ sudo usermod -a -G rvm $USER

SCRIPT

$provisionElasticsearch = <<SCRIPT
# Install Elastic Search
sudo apt install openjdk-17-jre-headless -y
sudo wget -O /usr/share/keyrings/elasticsearch.asc https://artifacts.elastic.co/GPG-KEY-elasticsearch
sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/elasticsearch.asc] https://artifacts.elastic.co/packages/7.x/apt stable main" > /etc/apt/sources.list.d/elastic-7.x.list'
sudo apt update
sudo apt install elasticsearch -y

sudo systemctl daemon-reload
sudo systemctl enable --now elasticsearch

echo 'path.data: /var/lib/elasticsearch
path.logs: /var/log/elasticsearch
network.host: 0.0.0.0
http.port: 9200
discovery.seed_hosts: ["localhost"]
cluster.initial_master_nodes: ["node-1"]' > /etc/elasticsearch/elasticsearch.yml

sudo systemctl restart elasticsearch

# Install Kibana
sudo apt install kibana -y
sudo systemctl enable --now kibana

echo 'server.host: "0.0.0.0"
elasticsearch.hosts: ["http://localhost:9200"]' > /etc/kibana/kibana.yml

sudo systemctl restart kibana

SCRIPT

$provisionB = <<SCRIPT

source "/etc/profile.d/rvm.sh"


@@ 102,10 133,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|

  config.vm.provider :virtualbox do |vb|
    vb.name = "mastodon"
    vb.customize ["modifyvm", :id, "--memory", "4096"]
    # Increase the number of CPUs. Uncomment and adjust to
    # increase performance
    # vb.customize ["modifyvm", :id, "--cpus", "3"]
    vb.customize ["modifyvm", :id, "--memory", "8192"]
    vb.customize ["modifyvm", :id, "--cpus", "3"]

    # Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
    # https://github.com/mitchellh/vagrant/issues/1172


@@ 141,9 170,15 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.network :forwarded_port, guest: 3000, host: 3000
  config.vm.network :forwarded_port, guest: 4000, host: 4000
  config.vm.network :forwarded_port, guest: 8080, host: 8080
  config.vm.network :forwarded_port, guest: 9200, host: 9200
  config.vm.network :forwarded_port, guest: 9300, host: 9300
  config.vm.network :forwarded_port, guest: 9243, host: 9243
  config.vm.network :forwarded_port, guest: 5601, host: 5601

  # Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
  config.vm.provision :shell, inline: $provisionA, privileged: false, reset: true
  # Run with elevated privileges for Elasticsearch installation
  config.vm.provision :shell, inline: $provisionElasticsearch, privileged: true
  config.vm.provision :shell, inline: $provisionB, privileged: false

  config.vm.post_up_message = <<MESSAGE

M app/chewy/instances_index.rb => app/chewy/instances_index.rb +1 -1
@@ 6,7 6,7 @@ class InstancesIndex < Chewy::Index
  index_scope ::Instance.searchable

  root date_detection: false do
    field :domain, type: 'text', index_prefixes: { min_chars: 1 }
    field :domain, type: 'text', index_prefixes: { min_chars: 1, max_chars: 5 }
    field :accounts_count, type: 'long'
  end
end

A app/controllers/api/v1/profile/avatars_controller.rb => app/controllers/api/v1/profile/avatars_controller.rb +13 -0
@@ 0,0 1,13 @@
# frozen_string_literal: true

class Api::V1::Profile::AvatarsController < Api::BaseController
  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
  before_action :require_user!

  def destroy
    @account = current_account
    UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true)
    ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
    render json: @account, serializer: REST::CredentialAccountSerializer
  end
end

A app/controllers/api/v1/profile/headers_controller.rb => app/controllers/api/v1/profile/headers_controller.rb +13 -0
@@ 0,0 1,13 @@
# frozen_string_literal: true

class Api::V1::Profile::HeadersController < Api::BaseController
  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
  before_action :require_user!

  def destroy
    @account = current_account
    UpdateAccountService.new.call(@account, { header: nil }, raise_error: true)
    ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
    render json: @account, serializer: REST::CredentialAccountSerializer
  end
end

M app/helpers/context_helper.rb => app/helpers/context_helper.rb +1 -0
@@ 22,6 22,7 @@ module ContextHelper
    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
    discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
    indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
    memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
    voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
    olm: {
      'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId',

M app/helpers/languages_helper.rb => app/helpers/languages_helper.rb +2 -0
@@ 188,6 188,7 @@ module LanguagesHelper

  ISO_639_3 = {
    ast: ['Asturian', 'Asturianu'].freeze,
    chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze,
    ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
    cnr: ['Montenegrin', 'crnogorski'].freeze,
    jbo: ['Lojban', 'la .lojban.'].freeze,


@@ 200,6 201,7 @@ module LanguagesHelper
    smj: ['Lule Sami', 'Julevsámegiella'].freeze,
    szl: ['Silesian', 'ślůnsko godka'].freeze,
    tok: ['Toki Pona', 'toki pona'].freeze,
    xal: ['Kalmyk', 'Хальмг келн'].freeze,
    zba: ['Balaibalan', 'باليبلن'].freeze,
    zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,
  }.freeze

M app/javascript/core/settings.js => app/javascript/core/settings.js +2 -10
@@ 18,22 18,14 @@ delegate(document, '#account_display_name', 'input', ({ target }) => {
  }
});

delegate(document, '#account_avatar', 'change', ({ target }) => {
  const avatar = document.querySelector('.card .avatar img');
delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
  const avatar = document.getElementById(target.id + '-preview');
  const [file] = target.files || [];
  const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;

  avatar.src = url;
});

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

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


M app/javascript/flavours/glitch/components/column.jsx => app/javascript/flavours/glitch/components/column.jsx +13 -1
@@ 18,7 18,19 @@ export default class Column extends PureComponent {
  };

  scrollTop () {
    const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
    let scrollable = null;

    if (this.props.bindToDocument) {
      scrollable = document.scrollingElement;
    } else {
      scrollable = this.node.querySelector('.scrollable');

      // Some columns have nested `.scrollable` containers, with the outer one
      // being a wrapper while the actual scrollable content is deeper.
      if (scrollable.classList.contains('scrollable--flex')) {
        scrollable = scrollable?.querySelector('.scrollable') || scrollable;
      }
   }

    if (!scrollable) {
      return;

M app/javascript/flavours/glitch/features/explore/results.jsx => app/javascript/flavours/glitch/features/explore/results.jsx +4 -4
@@ 110,10 110,10 @@ class Results extends PureComponent {
    return (
      <>
        <div className='account__section-headline'>
          <button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
          <button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
          <button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
          <button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
          <button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
          <button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
        </div>

        <div className='explore__search-results'>

M app/javascript/flavours/glitch/features/status/index.jsx => app/javascript/flavours/glitch/features/status/index.jsx +1 -1
@@ 595,7 595,7 @@ class Status extends ImmutablePureComponent {
        onMoveUp={this.handleMoveUp}
        onMoveDown={this.handleMoveDown}
        contextType='thread'
        previousId={i > 0 && list.get(i - 1)}
        previousId={i > 0 ? list.get(i - 1) : undefined}
        nextId={list.get(i + 1) || (ancestors && statusId)}
        rootId={statusId}
      />

M app/javascript/flavours/glitch/features/ui/components/modal_root.jsx => app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +4 -1
@@ 126,7 126,10 @@ export default class ModalRoot extends PureComponent {
        {visible && (
          <>
            <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
              {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
              {(SpecificComponent) => {
                const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
                return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />
              }}
            </BundleContainer>

            <Helmet>

M app/javascript/flavours/glitch/features/ui/components/report_modal.jsx => app/javascript/flavours/glitch/features/ui/components/report_modal.jsx +1 -1
@@ 63,7 63,7 @@ class ReportModal extends ImmutablePureComponent {
    dispatch(submitReport({
      account_id: accountId,
      status_ids: selectedStatusIds.toArray(),
      selected_domains: selectedDomains.toArray(),
      forward_to_domains: selectedDomains.toArray(),
      comment,
      forward: selectedDomains.size > 0,
      category,

M app/javascript/flavours/glitch/packs/public.jsx => app/javascript/flavours/glitch/packs/public.jsx +123 -126
@@ 14,7 14,6 @@ import emojify  from 'flavours/glitch/features/emoji/emoji';
import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
import { loadLocale, getLocale } from 'flavours/glitch/locales';
import { loadPolyfills } from 'flavours/glitch/polyfills';
import ready from 'flavours/glitch/ready';

const messages = defineMessages({
  usernameTaken: { id: 'username.taken', defaultMessage: 'That username is taken. Try another' },


@@ 42,159 41,157 @@ function main() {
    };
  };

  ready(() => {
    const locale = document.documentElement.lang;
  const locale = document.documentElement.lang;

    const dateTimeFormat = new Intl.DateTimeFormat(locale, {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      hour: 'numeric',
      minute: 'numeric',
    });
  const dateTimeFormat = new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: 'numeric',
    minute: 'numeric',
  });

    const dateFormat = new Intl.DateTimeFormat(locale, {
      year: 'numeric',
      month: 'short',
      day: 'numeric',
      timeFormat: false,
    });
  const dateFormat = new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    timeFormat: false,
  });

    const timeFormat = new Intl.DateTimeFormat(locale, {
      timeStyle: 'short',
      hour12: false,
    });
  const timeFormat = new Intl.DateTimeFormat(locale, {
    timeStyle: 'short',
    hour12: false,
  });

    const formatMessage = ({ id, defaultMessage }, values) => {
      const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
      return messageFormat.format(values);
    };
  const formatMessage = ({ id, defaultMessage }, values) => {
    const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
    return messageFormat.format(values);
  };

    [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
      content.innerHTML = emojify(content.innerHTML);
    });
  [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
    content.innerHTML = emojify(content.innerHTML);
  });

    [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
      const datetime = new Date(content.getAttribute('datetime'));
      const formattedDate = dateTimeFormat.format(datetime);
  [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
    const datetime = new Date(content.getAttribute('datetime'));
    const formattedDate = dateTimeFormat.format(datetime);

      content.title = formattedDate;
      content.textContent = formattedDate;
    });
    content.title = formattedDate;
    content.textContent = formattedDate;
  });

    const isToday = date => {
      const today = new Date();
  const isToday = date => {
    const today = new Date();

      return date.getDate() === today.getDate() &&
        date.getMonth() === today.getMonth() &&
        date.getFullYear() === today.getFullYear();
    };
    const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);
    return date.getDate() === today.getDate() &&
      date.getMonth() === today.getMonth() &&
      date.getFullYear() === today.getFullYear();
  };
  const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);

    [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
      const datetime = new Date(content.getAttribute('datetime'));
  [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
    const datetime = new Date(content.getAttribute('datetime'));

      let formattedContent;
    let formattedContent;

      if (isToday(datetime)) {
        const formattedTime = timeFormat.format(datetime);
    if (isToday(datetime)) {
      const formattedTime = timeFormat.format(datetime);

        formattedContent = todayFormat.format({ time: formattedTime });
      } else {
        formattedContent = dateFormat.format(datetime);
      }
      formattedContent = todayFormat.format({ time: formattedTime });
    } else {
      formattedContent = dateFormat.format(datetime);
    }

      content.title = formattedContent;
      content.textContent = formattedContent;
    });
    content.title = formattedContent;
    content.textContent = formattedContent;
  });

    [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
      const datetime = new Date(content.getAttribute('datetime'));
      const now      = new Date();
  [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
    const datetime = new Date(content.getAttribute('datetime'));
    const now      = new Date();

      const timeGiven = content.getAttribute('datetime').includes('T');
      content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
      content.textContent = timeAgoString({
        formatMessage,
        formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
      }, datetime, now, now.getFullYear(), timeGiven);
    });
    const timeGiven = content.getAttribute('datetime').includes('T');
    content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
    content.textContent = timeAgoString({
      formatMessage,
      formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
    }, datetime, now, now.getFullYear(), timeGiven);
  });

    const reactComponents = document.querySelectorAll('[data-component]');
    if (reactComponents.length > 0) {
      import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container')
        .then(({ default: MediaContainer }) => {
          [].forEach.call(reactComponents, (component) => {
            [].forEach.call(component.children, (child) => {
              component.removeChild(child);
            });
  const reactComponents = document.querySelectorAll('[data-component]');
  if (reactComponents.length > 0) {
    import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container')
      .then(({ default: MediaContainer }) => {
        [].forEach.call(reactComponents, (component) => {
          [].forEach.call(component.children, (child) => {
            component.removeChild(child);
          });

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

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

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

        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));
      }).catch(() => {
        username.setCustomValidity('');
      });
    } else {
      scrollToDetailedStatus();
      username.setCustomValidity('');
    }
  }, 500, { leading: false, trailing: true }));

    delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
      const username = document.getElementById('user_account_attributes_username');
  delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
    const password = document.getElementById('user_password');
    const confirmation = document.getElementById('user_password_confirmation');
    if (!confirmation) return;

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

    delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
      const password = document.getElementById('user_password');
      const confirmation = document.getElementById('user_password_confirmation');
      if (!confirmation) return;

      if (confirmation.value && confirmation.value.length > password.maxLength) {
        confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
      } else if (password.value && password.value !== confirmation.value) {
        confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
      } else {
        confirmation.setCustomValidity('');
      }
    });
    if (confirmation.value && confirmation.value.length > password.maxLength) {
      confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
    } else if (password.value && password.value !== confirmation.value) {
      confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
    } else {
      confirmation.setCustomValidity('');
    }
  });

    delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
    delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
  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;
  delegate(document, '.status__content__spoiler-link', 'click', function() {
    const statusEl = this.parentNode.parentNode;

      if (statusEl.dataset.spoiler === 'expanded') {
        statusEl.dataset.spoiler = 'folded';
        this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
      } else {
        statusEl.dataset.spoiler = 'expanded';
        this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
      }
    if (statusEl.dataset.spoiler === 'expanded') {
      statusEl.dataset.spoiler = 'folded';
      this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
    } else {
      statusEl.dataset.spoiler = 'expanded';
      this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
    }

      return false;
    });
    return false;
  });

    [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
      const statusEl = spoilerLink.parentNode.parentNode;
      const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
      spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
    });
  [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
    const statusEl = spoilerLink.parentNode.parentNode;
    const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
    spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
  });

  const toggleSidebar = () => {

M app/javascript/flavours/glitch/styles/components/modal.scss => app/javascript/flavours/glitch/styles/components/modal.scss +38 -0
@@ 1413,6 1413,44 @@ img.modal-warning {
    }
  }

  &__choices {
    display: flex;
    gap: 40px;

    &__choice {
      flex: 1;
      box-sizing: border-box;

      h3 {
        margin-bottom: 20px;
      }

      p {
        color: $darker-text-color;
        margin-bottom: 20px;
        font-size: 15px;
      }

      .button {
        margin-bottom: 10px;

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

  @media screen and (max-width: $no-gap-breakpoint - 1px) {
    &__choices {
      flex-direction: column;

      &__choice {
        margin-top: 40px;
      }
    }
  }

  .link-button {
    font-size: inherit;
    display: inline;

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

    &.active {
      transform: rotate(90deg);
      opacity: 1;
    }

    &:hover {

M app/javascript/flavours/glitch/styles/forms.scss => app/javascript/flavours/glitch/styles/forms.scss +10 -0
@@ 310,9 310,19 @@ code {
      border-radius: 4px;
      background: url('images/void.png');

      &[src$='missing.png'] {
        visibility: hidden;
      }

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

      &#account_avatar-preview {
        width: 90px;
        height: 90px;
        object-fit: cover;
      }
    }
  }


A app/javascript/mastodon/components/__tests__/hashtag_bar.tsx => app/javascript/mastodon/components/__tests__/hashtag_bar.tsx +199 -0
@@ 0,0 1,199 @@
import { fromJS } from 'immutable';

import type { StatusLike } from '../hashtag_bar';
import { computeHashtagBarForStatus } from '../hashtag_bar';

function createStatus(
  content: string,
  hashtags: string[],
  hasMedia = false,
  spoilerText?: string,
) {
  return fromJS({
    tags: hashtags.map((name) => ({ name })),
    contentHtml: content,
    media_attachments: hasMedia ? ['fakeMedia'] : [],
    spoiler_text: spoilerText,
  }) as unknown as StatusLike; // need to force the type here, as it is not properly defined
}

describe('computeHashtagBarForStatus', () => {
  it('does nothing when there are no tags', () => {
    const status = createStatus('<p>Simple text</p>', []);

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual([]);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>Simple text</p>"`,
    );
  });

  it('displays out of band hashtags in the bar', () => {
    const status = createStatus(
      '<p>Simple text <a href="test">#hashtag</a></p>',
      ['hashtag', 'test'],
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual(['test']);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>Simple text <a href="test">#hashtag</a></p>"`,
    );
  });

  it('extract tags from the last line', () => {
    const status = createStatus(
      '<p>Simple text</p><p><a href="test">#hashtag</a></p>',
      ['hashtag'],
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual(['hashtag']);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>Simple text</p>"`,
    );
  });

  it('does not include tags from content', () => {
    const status = createStatus(
      '<p>Simple text with a <a href="test">#hashtag</a></p><p><a href="test">#hashtag</a></p>',
      ['hashtag'],
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual([]);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>Simple text with a <a href="test">#hashtag</a></p>"`,
    );
  });

  it('works with one line status and hashtags', () => {
    const status = createStatus(
      '<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>',
      ['hashtag', 'test'],
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual([]);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>"`,
    );
  });

  it('de-duplicate accentuated characters with case differences', () => {
    const status = createStatus(
      '<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
      ['éaa'],
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual(['Éaa']);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>Text</p>"`,
    );
  });

  it('handles server-side normalized tags with accentuated characters', () => {
    const status = createStatus(
      '<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
      ['eaa'], // The server may normalize the hashtags in the `tags` attribute
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual(['Éaa']);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>Text</p>"`,
    );
  });

  it('does not display in bar a hashtag in content with a case difference', () => {
    const status = createStatus(
      '<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',
      ['éaa'],
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual([]);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>Text <a href="test">#Éaa</a></p>"`,
    );
  });

  it('does not modify a status with a line of hashtags only', () => {
    const status = createStatus(
      '<p><a href="test">#test</a>  <a href="test">#hashtag</a></p>',
      ['test', 'hashtag'],
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual([]);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p><a href="test">#test</a>  <a href="test">#hashtag</a></p>"`,
    );
  });

  it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => {
    const status = createStatus(
      '<p>This is my content! <a href="test">#hashtag</a></p>',
      ['hashtag'],
      true,
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual([]);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p>This is my content! <a href="test">#hashtag</a></p>"`,
    );
  });

  it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => {
    const status = createStatus(
      '<p><a href="test">#test</a>  <a href="test">#hashtag</a></p>',
      ['test', 'hashtag'],
      true,
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual(['test', 'hashtag']);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(`""`);
  });

  it('does not use the hashtag bar if the status content is only hashtags, has a CW and a media', () => {
    const status = createStatus(
      '<p><a href="test">#test</a>  <a href="test">#hashtag</a></p>',
      ['test', 'hashtag'],
      true,
      'My CW text',
    );

    const { hashtagsInBar, statusContentProps } =
      computeHashtagBarForStatus(status);

    expect(hashtagsInBar).toEqual([]);
    expect(statusContentProps.statusContent).toMatchInlineSnapshot(
      `"<p><a href="test">#test</a>  <a href="test">#hashtag</a></p>"`,
    );
  });
});

M app/javascript/mastodon/components/column.jsx => app/javascript/mastodon/components/column.jsx +13 -1
@@ 16,7 16,19 @@ export default class Column extends PureComponent {
  };

  scrollTop () {
    const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
    let scrollable = null;

    if (this.props.bindToDocument) {
      scrollable = document.scrollingElement;
    } else {
      scrollable = this.node.querySelector('.scrollable');

      // Some columns have nested `.scrollable` containers, with the outer one
      // being a wrapper while the actual scrollable content is deeper.
      if (scrollable.classList.contains('scrollable--flex')) {
        scrollable = scrollable?.querySelector('.scrollable') || scrollable;
      }
   }

    if (!scrollable) {
      return;

D app/javascript/mastodon/components/hashtag_bar.jsx => app/javascript/mastodon/components/hashtag_bar.jsx +0 -50
@@ 1,50 0,0 @@
import PropTypes from 'prop-types';
import { useMemo, useState, useCallback } from 'react';

import { FormattedMessage } from 'react-intl';

import { Link } from 'react-router-dom';

import ImmutablePropTypes from 'react-immutable-proptypes';

const domParser = new DOMParser();

// About two lines on desktop
const VISIBLE_HASHTAGS = 7;

export const HashtagBar = ({ hashtags, text }) => {
  const renderedHashtags = useMemo(() => {
    const body = domParser.parseFromString(text, 'text/html').documentElement;
    return [].filter.call(body.querySelectorAll('a[href]'), link => link.textContent[0] === '#' || (link.previousSibling?.textContent?.[link.previousSibling.textContent.length - 1] === '#')).map(node => node.textContent);
  }, [text]);

  const invisibleHashtags = useMemo(() => (
    hashtags.filter(hashtag => !renderedHashtags.some(textContent => textContent.localeCompare(`#${hashtag.get('name')}`, undefined, { sensitivity: 'accent' }) === 0 || textContent.localeCompare(hashtag.get('name'), undefined, { sensitivity: 'accent' }) === 0))
  ), [hashtags, renderedHashtags]);

  const [expanded, setExpanded] = useState(false);
  const handleClick = useCallback(() => setExpanded(true), []);

  if (invisibleHashtags.isEmpty()) {
    return null;
  }

  const revealedHashtags = expanded ? invisibleHashtags : invisibleHashtags.take(VISIBLE_HASHTAGS);

  return (
    <div className='hashtag-bar'>
      {revealedHashtags.map(hashtag => (
        <Link key={hashtag.get('name')} to={`/tags/${hashtag.get('name')}`}>
          #{hashtag.get('name')}
        </Link>
      ))}

      {!expanded && invisibleHashtags.size > VISIBLE_HASHTAGS && <button className='link-button' onClick={handleClick}><FormattedMessage id='hashtags.and_other' defaultMessage='…and {count, plural, other {# more}}' values={{ count: invisibleHashtags.size - VISIBLE_HASHTAGS }} /></button>}
    </div>
  );
};

HashtagBar.propTypes = {
  hashtags: ImmutablePropTypes.list,
  text: PropTypes.string,
};

A app/javascript/mastodon/components/hashtag_bar.tsx => app/javascript/mastodon/components/hashtag_bar.tsx +234 -0
@@ 0,0 1,234 @@
import { useState, useCallback } from 'react';

import { FormattedMessage } from 'react-intl';

import { Link } from 'react-router-dom';

import type { List, Record } from 'immutable';

import { groupBy, minBy } from 'lodash';

import { getStatusContent } from './status_content';

// About two lines on desktop
const VISIBLE_HASHTAGS = 7;

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

function normalizeHashtag(hashtag: string) {
  return (
    hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
  ).normalize('NFKC');
}

function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
  return (
    element instanceof HTMLAnchorElement &&
    // it may be a <a> starting with a hashtag
    (element.textContent?.[0] === '#' ||
      // or a #<a>
      element.previousSibling?.textContent?.[
        element.previousSibling.textContent.length - 1
      ] === '#')
  );
}

/**
 * Removes duplicates from an hashtag list, case-insensitive, keeping only the best one
 * "Best" here is defined by the one with the more casing difference (ie, the most camel-cased one)
 * @param hashtags The list of hashtags
 * @returns The input hashtags, but with only 1 occurence of each (case-insensitive)
 */
function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
  const groups = groupBy(hashtags, (tag) =>
    tag.normalize('NFKD').toLowerCase(),
  );

  return Object.values(groups).map((tags) => {
    if (tags.length === 1) return tags[0];

    // The best match is the one where we have the less difference between upper and lower case letter count
    const best = minBy(tags, (tag) => {
      const upperCase = Array.from(tag).reduce(
        (acc, char) => (acc += char.toUpperCase() === char ? 1 : 0),
        0,
      );

      const lowerCase = tag.length - upperCase;

      return Math.abs(lowerCase - upperCase);
    });

    return best ?? tags[0];
  });
}

// Create the collator once, this is much more efficient
const collator = new Intl.Collator(undefined, {
  sensitivity: 'base', // we use this to emulate the ASCII folding done on the server-side, hopefuly more efficiently
});

function localeAwareInclude(collection: string[], value: string) {
  const normalizedValue = value.normalize('NFKC');

  return !!collection.find(
    (item) => collator.compare(item.normalize('NFKC'), normalizedValue) === 0,
  );
}

// We use an intermediate function here to make it easier to test
export function computeHashtagBarForStatus(status: StatusLike): {
  statusContentProps: { statusContent: string };
  hashtagsInBar: string[];
} {
  let statusContent = getStatusContent(status);

  const tagNames = status
    .get('tags')
    .map((tag) => tag.get('name'))
    .toJS();

  // this is returned if we stop the processing early, it does not change what is displayed
  const defaultResult = {
    statusContentProps: { statusContent },
    hashtagsInBar: [],
  };

  // return early if this status does not have any tags
  if (tagNames.length === 0) return defaultResult;

  const template = document.createElement('template');
  template.innerHTML = statusContent.trim();

  const lastChild = template.content.lastChild;

  if (!lastChild) return defaultResult;

  template.content.removeChild(lastChild);
  const contentWithoutLastLine = template;

  // First, try to parse
  const contentHashtags = Array.from(
    contentWithoutLastLine.content.querySelectorAll<HTMLLinkElement>('a[href]'),
  ).reduce<string[]>((result, link) => {
    if (isNodeLinkHashtag(link)) {
      if (link.textContent) result.push(normalizeHashtag(link.textContent));
    }
    return result;
  }, []);

  // Now we parse the last line, and try to see if it only contains hashtags
  const lastLineHashtags: string[] = [];
  // try to see if the last line is only hashtags
  let onlyHashtags = true;

  const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC'));

  Array.from(lastChild.childNodes).forEach((node) => {
    if (isNodeLinkHashtag(node) && node.textContent) {
      const normalized = normalizeHashtag(node.textContent);

      if (!localeAwareInclude(normalizedTagNames, normalized)) {
        // stop here, this is not a real hashtag, so consider it as text
        onlyHashtags = false;
        return;
      }

      if (!localeAwareInclude(contentHashtags, normalized))
        // only add it if it does not appear in the rest of the content
        lastLineHashtags.push(normalized);
    } else if (node.nodeType !== Node.TEXT_NODE || node.nodeValue?.trim()) {
      // not a space
      onlyHashtags = false;
    }
  });

  const hashtagsInBar = tagNames.filter((tag) => {
    const normalizedTag = tag.normalize('NFKC');
    // the tag does not appear at all in the status content, it is an out-of-band tag
    return (
      !localeAwareInclude(contentHashtags, normalizedTag) &&
      !localeAwareInclude(lastLineHashtags, normalizedTag)
    );
  });

  const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0;
  const hasMedia = status.get('media_attachments').size > 0;
  const hasSpoiler = !!status.get('spoiler_text');

  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- due to https://github.com/microsoft/TypeScript/issues/9998
  if (onlyHashtags && ((hasMedia && !hasSpoiler) || !isOnlyOneLine)) {
    // if the last line only contains hashtags, and we either:
    // - have other content in the status
    // - dont have other content, but a media and no CW. If it has a CW, then we do not remove the content to avoid having an empty content behind the CW button
    statusContent = contentWithoutLastLine.innerHTML;
    // and add the tags to the bar
    hashtagsInBar.push(...lastLineHashtags);
  }

  return {
    statusContentProps: { statusContent },
    hashtagsInBar: uniqueHashtagsWithCaseHandling(hashtagsInBar),
  };
}

/**
 *  This function will process a status to, at the same time (avoiding parsing it twice):
 * - build the HashtagBar for this status
 * - remove the last-line hashtags from the status content
 * @param status The status to process
 * @returns Props to be passed to the <StatusContent> component, and the hashtagBar to render
 */
export function getHashtagBarForStatus(status: StatusLike) {
  const { statusContentProps, hashtagsInBar } =
    computeHashtagBarForStatus(status);

  return {
    statusContentProps,
    hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
  };
}

const HashtagBar: React.FC<{
  hashtags: string[];
}> = ({ hashtags }) => {
  const [expanded, setExpanded] = useState(false);
  const handleClick = useCallback(() => {
    setExpanded(true);
  }, []);

  if (hashtags.length === 0) {
    return null;
  }

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

  return (
    <div className='hashtag-bar'>
      {revealedHashtags.map((hashtag) => (
        <Link key={hashtag} to={`/tags/${hashtag}`}>
          #<span>{hashtag}</span>
        </Link>
      ))}

      {!expanded && hashtags.length > VISIBLE_HASHTAGS && (
        <button className='link-button' onClick={handleClick}>
          <FormattedMessage
            id='hashtags.and_other'
            defaultMessage='…and {count, plural, other {# more}}'
            values={{ count: hashtags.length - VISIBLE_HASHTAGS }}
          />
        </button>
      )}
    </div>
  );
};

M app/javascript/mastodon/components/status.jsx => app/javascript/mastodon/components/status.jsx +7 -3
@@ 22,7 22,7 @@ import { displayMedia } from '../initial_state';
import { Avatar } from './avatar';
import { AvatarOverlay } from './avatar_overlay';
import { DisplayName } from './display_name';
import { HashtagBar } from './hashtag_bar';
import { getHashtagBarForStatus } from './hashtag_bar';
import { RelativeTimestamp } from './relative_timestamp';
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';


@@ 545,6 545,9 @@ class Status extends ImmutablePureComponent {

    const visibilityIcon = visibilityIconInfo[status.get('visibility')];

    const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
    const expanded = !status.get('hidden')

    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}>


@@ 572,16 575,17 @@ class Status extends ImmutablePureComponent {
            <StatusContent
              status={status}
              onClick={this.handleClick}
              expanded={!status.get('hidden')}
              expanded={expanded}
              onExpandedToggle={this.handleExpandedToggle}
              onTranslate={this.handleTranslate}
              collapsible
              onCollapsedToggle={this.handleCollapsedToggle}
              {...statusContentProps}
            />

            {media}

            <HashtagBar hashtags={status.get('tags')} text={status.get('content')} />
            {expanded && hashtagBar}

            <StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
          </div>

M app/javascript/mastodon/components/status_content.jsx => app/javascript/mastodon/components/status_content.jsx +12 -2
@@ 15,6 15,15 @@ import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_s

const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)

/**
 *
 * @param {any} status
 * @returns {string}
 */
export function getStatusContent(status) {
  return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
}

class TranslateButton extends PureComponent {

  static propTypes = {


@@ 65,6 74,7 @@ class StatusContent extends PureComponent {

  static propTypes = {
    status: ImmutablePropTypes.map.isRequired,
    statusContent: PropTypes.string,
    expanded: PropTypes.bool,
    onExpandedToggle: PropTypes.func,
    onTranslate: PropTypes.func,


@@ 225,7 235,7 @@ class StatusContent extends PureComponent {
  };

  render () {
    const { status, intl } = this.props;
    const { status, intl, statusContent } = this.props;

    const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
    const renderReadMore = this.props.onClick && status.get('collapsed');


@@ 233,7 243,7 @@ class StatusContent extends PureComponent {
    const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
    const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);

    const content = { __html: status.getIn(['translation', 'contentHtml']) || status.get('contentHtml') };
    const content = { __html: statusContent ?? getStatusContent(status) };
    const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
    const language = status.getIn(['translation', 'language']) || status.get('language');
    const classNames = classnames('status__content', {

M app/javascript/mastodon/features/explore/results.jsx => app/javascript/mastodon/features/explore/results.jsx +4 -4
@@ 108,10 108,10 @@ class Results extends PureComponent {
    return (
      <>
        <div className='account__section-headline'>
          <button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
          <button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
          <button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
          <button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
          <button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
          <button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
        </div>

        <div className='explore__search-results'>

M app/javascript/mastodon/features/status/components/detailed_status.jsx => app/javascript/mastodon/features/status/components/detailed_status.jsx +6 -2
@@ 10,7 10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';

import { AnimatedNumber } from 'mastodon/components/animated_number';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { HashtagBar } from 'mastodon/components/hashtag_bar';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon }  from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';



@@ 292,6 292,9 @@ class DetailedStatus extends ImmutablePureComponent {
      );
    }

    const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
    const expanded = !status.get('hidden')

    return (
      <div style={outerStyle}>
        <div ref={this.setRef} className={classNames('detailed-status', { compact })}>


@@ 311,11 314,12 @@ class DetailedStatus extends ImmutablePureComponent {
            expanded={!status.get('hidden')}
            onExpandedToggle={this.handleExpandedToggle}
            onTranslate={this.handleTranslate}
            {...statusContentProps}
          />

          {media}

          <HashtagBar hashtags={status.get('tags')} text={status.get('content')} />
          {expanded && hashtagBar}

          <div className='detailed-status__meta'>
            <a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>

M app/javascript/mastodon/features/status/index.jsx => app/javascript/mastodon/features/status/index.jsx +1 -1
@@ 568,7 568,7 @@ class Status extends ImmutablePureComponent {
        onMoveUp={this.handleMoveUp}
        onMoveDown={this.handleMoveDown}
        contextType='thread'
        previousId={i > 0 && list.get(i - 1)}
        previousId={i > 0 ? list.get(i - 1) : undefined}
        nextId={list.get(i + 1) || (ancestors && statusId)}
        rootId={statusId}
      />

M app/javascript/mastodon/features/ui/components/modal_root.jsx => app/javascript/mastodon/features/ui/components/modal_root.jsx +4 -1
@@ 115,7 115,10 @@ export default class ModalRoot extends PureComponent {
        {visible && (
          <>
            <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
              {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
              {(SpecificComponent) => {
                const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
                return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />
              }}
            </BundleContainer>

            <Helmet>

M app/javascript/mastodon/features/ui/components/report_modal.jsx => app/javascript/mastodon/features/ui/components/report_modal.jsx +1 -1
@@ 62,7 62,7 @@ class ReportModal extends ImmutablePureComponent {
    dispatch(submitReport({
      account_id: accountId,
      status_ids: selectedStatusIds.toArray(),
      selected_domains: selectedDomains.toArray(),
      forward_to_domains: selectedDomains.toArray(),
      comment,
      forward: selectedDomains.size > 0,
      category,

M app/javascript/packs/public.jsx => app/javascript/packs/public.jsx +166 -169
@@ 48,201 48,198 @@ function loaded() {
    };
  };

  ready(() => {
    const locale = document.documentElement.lang;

    const dateTimeFormat = new Intl.DateTimeFormat(locale, {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      hour: 'numeric',
      minute: 'numeric',
    });

    const dateFormat = new Intl.DateTimeFormat(locale, {
      year: 'numeric',
      month: 'short',
      day: 'numeric',
      timeFormat: false,
    });

    const timeFormat = new Intl.DateTimeFormat(locale, {
      timeStyle: 'short',
      hour12: false,
    });

    const formatMessage = ({ id, defaultMessage }, values) => {
      const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
      return messageFormat.format(values);
    };
  const locale = document.documentElement.lang;

  const dateTimeFormat = new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: 'numeric',
    minute: 'numeric',
  });

    [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
      content.innerHTML = emojify(content.innerHTML);
    });
  const dateFormat = new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    timeFormat: false,
  });

    [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
      const datetime = new Date(content.getAttribute('datetime'));
      const formattedDate = dateTimeFormat.format(datetime);
  const timeFormat = new Intl.DateTimeFormat(locale, {
    timeStyle: 'short',
    hour12: false,
  });

      content.title = formattedDate;
      content.textContent = formattedDate;
    });
  const formatMessage = ({ id, defaultMessage }, values) => {
    const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
    return messageFormat.format(values);
  };

    const isToday = date => {
      const today = new Date();
  [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
    content.innerHTML = emojify(content.innerHTML);
  });

      return date.getDate() === today.getDate() &&
        date.getMonth() === today.getMonth() &&
        date.getFullYear() === today.getFullYear();
    };
    const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);

    [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
      const datetime = new Date(content.getAttribute('datetime'));

      let formattedContent;

      if (isToday(datetime)) {
        const formattedTime = timeFormat.format(datetime);

        formattedContent = todayFormat.format({ time: formattedTime });
      } else {
        formattedContent = dateFormat.format(datetime);
      }

      content.title = formattedContent;
      content.textContent = formattedContent;
    });

    [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
      const datetime = new Date(content.getAttribute('datetime'));
      const now      = new Date();

      const timeGiven = content.getAttribute('datetime').includes('T');
      content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
      content.textContent = timeAgoString({
        formatMessage,
        formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
      }, datetime, now, now.getFullYear(), timeGiven);
    });

    const reactComponents = document.querySelectorAll('[data-component]');

    if (reactComponents.length > 0) {
      import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
        .then(({ default: MediaContainer }) => {
          [].forEach.call(reactComponents, (component) => {
            [].forEach.call(component.children, (child) => {
              component.removeChild(child);
            });
          });
  [].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
    const datetime = new Date(content.getAttribute('datetime'));
    const formattedDate = dateTimeFormat.format(datetime);

          const content = document.createElement('div');
    content.title = formattedDate;
    content.textContent = formattedDate;
  });

          const root = createRoot(content);
          root.render(<MediaContainer locale={locale} components={reactComponents} />);
          document.body.appendChild(content);
          scrollToDetailedStatus();
        })
        .catch(error => {
          console.error(error);
          scrollToDetailedStatus();
        });
  const isToday = date => {
    const today = new Date();

    return date.getDate() === today.getDate() &&
      date.getMonth() === today.getMonth() &&
      date.getFullYear() === today.getFullYear();
  };
  const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);

  [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
    const datetime = new Date(content.getAttribute('datetime'));

    let formattedContent;

    if (isToday(datetime)) {
      const formattedTime = timeFormat.format(datetime);

      formattedContent = todayFormat.format({ time: formattedTime });
    } else {
      scrollToDetailedStatus();
      formattedContent = dateFormat.format(datetime);
    }

    delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
      const username = document.getElementById('user_account_attributes_username');
    content.title = formattedContent;
    content.textContent = formattedContent;
  });

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

    delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
      const password = document.getElementById('user_password');
      const confirmation = document.getElementById('user_password_confirmation');
      if (!confirmation) return;

      if (confirmation.value && confirmation.value.length > password.maxLength) {
        confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
      } else if (password.value && password.value !== confirmation.value) {
        confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
      } else {
        confirmation.setCustomValidity('');
      }
    });

    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;

      if (statusEl.dataset.spoiler === 'expanded') {
        statusEl.dataset.spoiler = 'folded';
        this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
      } else {
        statusEl.dataset.spoiler = 'expanded';
        this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
      }

      return false;
    });

    [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
      const statusEl = spoilerLink.parentNode.parentNode;
      const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
      spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
    });
  [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
    const datetime = new Date(content.getAttribute('datetime'));
    const now      = new Date();

    const timeGiven = content.getAttribute('datetime').includes('T');
    content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
    content.textContent = timeAgoString({
      formatMessage,
      formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
    }, datetime, now, now.getFullYear(), timeGiven);
  });

  const toggleSidebar = () => {
    const sidebar = document.querySelector('.sidebar ul');
    const toggleButton = document.querySelector('.sidebar__toggle__icon');
  const reactComponents = document.querySelectorAll('[data-component]');

    if (sidebar.classList.contains('visible')) {
      document.body.style.overflow = null;
      toggleButton.setAttribute('aria-expanded', 'false');
  if (reactComponents.length > 0) {
    import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
      .then(({ default: MediaContainer }) => {
        [].forEach.call(reactComponents, (component) => {
          [].forEach.call(component.children, (child) => {
            component.removeChild(child);
          });
        });

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

        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));
      }).catch(() => {
        username.setCustomValidity('');
      });
    } else {
      document.body.style.overflow = 'hidden';
      toggleButton.setAttribute('aria-expanded', 'true');
      username.setCustomValidity('');
    }
  }, 500, { leading: false, trailing: true }));

    toggleButton.classList.toggle('active');
    sidebar.classList.toggle('visible');
  };
  delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
    const password = document.getElementById('user_password');
    const confirmation = document.getElementById('user_password_confirmation');
    if (!confirmation) return;

  delegate(document, '.sidebar__toggle__icon', 'click', () => {
    toggleSidebar();
    if (confirmation.value && confirmation.value.length > password.maxLength) {
      confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
    } else if (password.value && password.value !== confirmation.value) {
      confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
    } else {
      confirmation.setCustomValidity('');
    }
  });

  delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      toggleSidebar();
  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;

    if (statusEl.dataset.spoiler === 'expanded') {
      statusEl.dataset.spoiler = 'folded';
      this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
    } else {
      statusEl.dataset.spoiler = 'expanded';
      this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
    }

    return false;
  });

  // Empty the honeypot fields in JS in case something like an extension
  // automatically filled them.
  delegate(document, '#registration_new_user,#new_user', 'submit', () => {
    ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
      const field = document.getElementById(id);
      if (field) {
        field.value = '';
      }
    });
  [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
    const statusEl = spoilerLink.parentNode.parentNode;
    const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
    spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
  });
}

const toggleSidebar = () => {
  const sidebar = document.querySelector('.sidebar ul');
  const toggleButton = document.querySelector('.sidebar__toggle__icon');

  if (sidebar.classList.contains('visible')) {
    document.body.style.overflow = null;
    toggleButton.setAttribute('aria-expanded', 'false');
  } else {
    document.body.style.overflow = 'hidden';
    toggleButton.setAttribute('aria-expanded', 'true');
  }

  toggleButton.classList.toggle('active');
  sidebar.classList.toggle('visible');
};

delegate(document, '.sidebar__toggle__icon', 'click', () => {
  toggleSidebar();
});

delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
  if (e.key === ' ' || e.key === 'Enter') {
    e.preventDefault();
    toggleSidebar();
  }
});

// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
delegate(document, '#registration_new_user,#new_user', 'submit', () => {
  ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
    const field = document.getElementById(id);
    if (field) {
      field.value = '';
    }
  });
});

function main() {
  ready(loaded);

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +46 -8
@@ 5125,6 5125,7 @@ a.status-card {

    &.active {
      transform: rotate(90deg);
      opacity: 1;
    }

    &:hover {


@@ 8539,6 8540,44 @@ noscript {
    }
  }

  &__choices {
    display: flex;
    gap: 40px;

    &__choice {
      flex: 1;
      box-sizing: border-box;

      h3 {
        margin-bottom: 20px;
      }

      p {
        color: $darker-text-color;
        margin-bottom: 20px;
        font-size: 15px;
      }

      .button {
        margin-bottom: 10px;

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

  @media screen and (max-width: $no-gap-breakpoint - 1px) {
    &__choices {
      flex-direction: column;

      &__choice {
        margin-top: 40px;
      }
    }
  }

  .link-button {
    font-size: inherit;
    display: inline;


@@ 9267,16 9306,15 @@ noscript {

  a {
    display: inline-flex;
    border-radius: 4px;
    background: rgba($highlight-text-color, 0.2);
    color: $highlight-text-color;
    padding: 0.4em 0.6em;
    color: $dark-text-color;
    text-decoration: none;

    &:hover,
    &:focus,
    &:active {
      background: rgba($highlight-text-color, 0.3);
    &:hover {
      text-decoration: none;

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

M app/javascript/styles/mastodon/forms.scss => app/javascript/styles/mastodon/forms.scss +10 -0
@@ 309,9 309,19 @@ code {
      border-radius: 4px;
      background: url('images/void.png');

      &[src$='missing.png'] {
        visibility: hidden;
      }

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

      &#account_avatar-preview {
        width: 90px;
        height: 90px;
        object-fit: cover;
      }
    }
  }


M app/lib/activitypub/activity/create.rb => app/lib/activitypub/activity/create.rb +2 -0
@@ 4,6 4,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
  include FormattingHelper

  def perform
    @account.schedule_refresh_if_stale!

    dereference_object!

    case @object['type']

M app/lib/activitypub/activity/update.rb => app/lib/activitypub/activity/update.rb +2 -0
@@ 2,6 2,8 @@

class ActivityPub::Activity::Update < ActivityPub::Activity
  def perform
    @account.schedule_refresh_if_stale!

    dereference_object!

    if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service))

M app/lib/admin/system_check/elasticsearch_check.rb => app/lib/admin/system_check/elasticsearch_check.rb +58 -3
@@ 1,6 1,13 @@
# frozen_string_literal: true

class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
  INDEXES = [
    InstancesIndex,
    AccountsIndex,
    TagsIndex,
    StatusesIndex,
  ].freeze

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


@@ 8,11 15,15 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
  def pass?
    return true unless Chewy.enabled?

    running_version.present? && compatible_version?
    running_version.present? && compatible_version? && cluster_health['status'] == 'green' && indexes_match? && preset_matches?
  rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
    false
  end

  def message
    if running_version.present?
    if running_version.blank?
      Admin::SystemCheck::Message.new(:elasticsearch_running_check)
    elsif !compatible_version?
      Admin::SystemCheck::Message.new(
        :elasticsearch_version_check,
        I18n.t(


@@ 21,13 32,32 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
          required_version: required_version
        )
      )
    elsif !indexes_match?
      Admin::SystemCheck::Message.new(
        :elasticsearch_index_mismatch,
        mismatched_indexes.join(' ')
      )
    elsif cluster_health['status'] == 'red'
      Admin::SystemCheck::Message.new(:elasticsearch_health_red)
    elsif cluster_health['number_of_nodes'] < 2 && es_preset != 'single_node_cluster'
      Admin::SystemCheck::Message.new(:elasticsearch_preset_single_node, nil, 'https://docs.joinmastodon.org/admin/optional/elasticsearch/#scaling')
    elsif Chewy.client.indices.get_settings[Chewy::Stash::Specification.index_name]&.dig('settings', 'index', 'number_of_replicas')&.to_i&.positive? && es_preset == 'single_node_cluster'
      Admin::SystemCheck::Message.new(:elasticsearch_reset_chewy)
    elsif cluster_health['status'] == 'yellow'
      Admin::SystemCheck::Message.new(:elasticsearch_health_yellow)
    else
      Admin::SystemCheck::Message.new(:elasticsearch_running_check)
      Admin::SystemCheck::Message.new(:elasticsearch_preset, nil, 'https://docs.joinmastodon.org/admin/optional/elasticsearch/#scaling')
    end
  rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
    Admin::SystemCheck::Message.new(:elasticsearch_running_check)
  end

  private

  def cluster_health
    @cluster_health ||= Chewy.client.cluster.health
  end

  def running_version
    @running_version ||= begin
      Chewy.client.info['version']['number']


@@ 49,5 79,30 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck

    Gem::Version.new(running_version) >= Gem::Version.new(required_version) ||
      Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version)
  rescue ArgumentError
    false
  end

  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
    end
  end

  def indexes_match?
    mismatched_indexes.empty?
  end

  def es_preset
    ENV.fetch('ES_PRESET', 'single_node_cluster')
  end

  def preset_matches?
    case es_preset
    when 'single_node_cluster'
      cluster_health['number_of_nodes'] == 1
    else
      cluster_health['number_of_nodes'] > 1
    end
  end
end

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

  protected

  def in_work_unit(*args, &block)
    work_unit = Concurrent::Promises.future_on(@executor, *args, &block)
  def in_work_unit(...)
    work_unit = Concurrent::Promises.future_on(@executor, ...)

    work_unit.on_fulfillment!(&@on_progress)
    work_unit.on_rejection!(&@on_failure)

M app/models/account.rb => app/models/account.rb +8 -0
@@ 63,6 63,8 @@ class Account < ApplicationRecord
    trust_level
  )

  BACKGROUND_REFRESH_INTERVAL = 1.week.freeze

  USERNAME_RE   = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i
  MENTION_RE    = %r{(?<=^|[^/[:word:]])@((#{USERNAME_RE})(?:@[[:word:].-]+[[:word:]]+)?)}i
  URL_PREFIX_RE = %r{\Ahttp(s?)://[^/]+}


@@ 213,6 215,12 @@ class Account < ApplicationRecord
    last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
  end

  def schedule_refresh_if_stale!
    return unless last_webfingered_at.present? && last_webfingered_at <= BACKGROUND_REFRESH_INTERVAL.ago

    AccountRefreshWorker.perform_in(rand(6.hours.to_i), id)
  end

  def refresh!
    ResolveAccountService.new.call(acct) unless local?
  end

M app/models/follow_recommendation.rb => app/models/follow_recommendation.rb +2 -1
@@ 2,7 2,7 @@

# == Schema Information
#
# Table name: follow_recommendations
# Table name: global_follow_recommendations
#
#  account_id :bigint(8)        primary key
#  rank       :decimal(, )


@@ 11,6 11,7 @@

class FollowRecommendation < ApplicationRecord
  self.primary_key = :account_id
  self.table_name = :global_follow_recommendations

  belongs_to :account_summary, foreign_key: :account_id, inverse_of: false
  belongs_to :account

M app/models/public_feed.rb => app/models/public_feed.rb +2 -2
@@ 51,11 51,11 @@ class PublicFeed
  end

  def local_only?
    options[:local]
    options[:local] && !options[:remote]
  end

  def remote_only?
    options[:remote]
    options[:remote] && !options[:local]
  end

  def account?

M app/serializers/activitypub/actor_serializer.rb => app/serializers/activitypub/actor_serializer.rb +3 -2
@@ 7,13 7,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
  context :security

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

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

  has_one :public_key, serializer: ActivityPub::PublicKeySerializer


M app/services/account_search_service.rb => app/services/account_search_service.rb +1 -1
@@ 124,7 124,7 @@ class AccountSearchService < BaseService
        multi_match: {
          query: @query,
          type: 'bool_prefix',
          fields: %w(username username.* display_name display_name.*),
          fields: %w(username^2 username.*^2 display_name display_name.*),
        },
      }
    end

M app/services/activitypub/process_account_service.rb => app/services/activitypub/process_account_service.rb +1 -0
@@ 116,6 116,7 @@ class ActivityPub::ProcessAccountService < BaseService
    @account.also_known_as           = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
    @account.discoverable            = @json['discoverable'] || false
    @account.indexable               = @json['indexable'] || false
    @account.memorial                = @json['memorial'] || false
  end

  def set_fetchable_key!

M app/views/settings/preferences/notifications/show.html.haml => app/views/settings/preferences/notifications/show.html.haml +11 -6
@@ 18,16 18,21 @@
      = ff.input :'notification_emails.reblog', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.reblog')
      = ff.input :'notification_emails.favourite', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.favourite')
      = ff.input :'notification_emails.mention', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.mention')
      = ff.input :'notification_emails.report', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.report') if current_user.can?(:manage_reports)
      = ff.input :'notification_emails.appeal', as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.appeal') if current_user.can?(:manage_appeals)
      = ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users)
      = ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies)
      = 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)

    .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)
      %h4= t 'notifications.administration_emails'

      .fields-group
        = ff.input :'notification_emails.report', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.report') if current_user.can?(:manage_reports)
        = ff.input :'notification_emails.appeal', as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.appeal') if current_user.can?(:manage_appeals)
        = ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users)
        = ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies)
        = 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)

  %h4= t 'notifications.other_settings'

  .fields-group

M app/views/settings/profiles/show.html.haml => app/views/settings/profiles/show.html.haml +8 -8
@@ 35,10 35,10 @@
      .fields-group
        = f.input :avatar, wrapper: :with_block_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT))

    - if @account.avatar.present?
      .fields-row__column.fields-row__column-6
        .fields-group
          = image_tag @account.avatar.url, class: 'fields-group__thumbnail', width: 90, height: 90
    .fields-row__column.fields-row__column-6
      .fields-group
        = image_tag @account.avatar.url, class: 'fields-group__thumbnail', id: 'account_avatar-preview'
        - if @account.avatar.present?
          = link_to settings_profile_picture_path('avatar'), data: { method: :delete }, class: 'link-button link-button--destructive' do
            = fa_icon 'trash fw'
            = t('generic.delete')


@@ 48,10 48,10 @@
      .fields-group
        = f.input :header, wrapper: :with_block_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header', dimensions: '1500x500', size: number_to_human_size(AccountHeader::LIMIT))

    - if @account.header.present?
      .fields-row__column.fields-row__column-6
        .fields-group
          = image_tag @account.header.url, class: 'fields-group__thumbnail'
    .fields-row__column.fields-row__column-6
      .fields-group
        = image_tag @account.header.url, class: 'fields-group__thumbnail', id: 'account_header-preview'
        - if @account.header.present?
          = link_to settings_profile_picture_path('header'), data: { method: :delete }, class: 'link-button link-button--destructive' do
            = fa_icon 'trash fw'
            = t('generic.delete')

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

class AccountRefreshWorker
  include Sidekiq::Worker

  sidekiq_options queue: 'pull', retry: 3, dead: false, lock: :until_executed, lock_ttl: 1.day.to_i

  def perform(account_id)
    account = Account.find_by(id: account_id)
    return if account.nil? || account.last_webfingered_at > Account::BACKGROUND_REFRESH_INTERVAL.ago

    ResolveAccountService.new.call(account)
  end
end

M config/locales/en.yml => config/locales/en.yml +15 -0
@@ 814,6 814,20 @@ en:
    system_checks:
      database_schema_check:
        message_html: There are pending database migrations. Please run them to ensure the application behaves as expected
      elasticsearch_health_red:
        message_html: Elasticsearch cluster is unhealthy (red status), search features are unavailable
      elasticsearch_health_yellow:
        message_html: Elasticsearch cluster is unhealthy (yellow status), you may want to investigate the reason
      elasticsearch_index_mismatch:
        message_html: Elasticsearch index mappings are outdated. Please run <code>tootctl search deploy --only=%{value}</code>
      elasticsearch_preset:
        action: See documentation
        message_html: Your Elasticsearch cluster has more than one node, but Mastodon is not configured to use them.
      elasticsearch_preset_single_node:
        action: See documentation
        message_html: Your Elasticsearch cluster has only one node, <code>ES_PRESET</code> should be set to <code>single_node_cluster</code>.
      elasticsearch_reset_chewy:
        message_html: Your Elasticsearch system index is outdated due to a setting change. Please run <code>tootctl search deploy --reset-chewy</code> to update it.
      elasticsearch_running_check:
        message_html: Could not connect to Elasticsearch. Please check that it is running, or disable full-text search
      elasticsearch_version_check:


@@ 1432,6 1446,7 @@ en:
    update:
      subject: "%{name} edited a post"
  notifications:
    administration_emails: Admin e-mail notifications
    email_events: Events for e-mail notifications
    email_events_hint: 'Select events that you want to receive notifications for:'
    other_settings: Other notifications settings

M config/routes/api.rb => config/routes/api.rb +5 -0
@@ 97,6 97,11 @@ namespace :api, format: false do
    resources :endorsements, only: [:index]
    resources :markers, only: [:index, :create]

    namespace :profile do
      resource :avatar, only: :destroy
      resource :header, only: :destroy
    end

    namespace :apps do
      get :verify_credentials, to: 'credentials#show'
    end

M config/webpack/shared.js => config/webpack/shared.js +4 -0
@@ 2,6 2,7 @@

const { resolve } = require('path');

const CircularDependencyPlugin = require('circular-dependency-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack');
const AssetsManifestPlugin = require('webpack-assets-manifest');


@@ 112,6 113,9 @@ module.exports = {
      writeToDisk: true,
      publicPath: true,
    }),
    new CircularDependencyPlugin({
      failOnError: true,
    })
  ],

  resolve: {

A db/migrate/20230818141056_create_global_follow_recommendations.rb => db/migrate/20230818141056_create_global_follow_recommendations.rb +8 -0
@@ 0,0 1,8 @@
# frozen_string_literal: true

class CreateGlobalFollowRecommendations < ActiveRecord::Migration[7.0]
  def change
    create_view :global_follow_recommendations, materialized: { no_data: true }
    safety_assured { add_index :global_follow_recommendations, :account_id, unique: true }
  end
end

A db/post_migrate/20230818142253_drop_follow_recommendations.rb => db/post_migrate/20230818142253_drop_follow_recommendations.rb +12 -0
@@ 0,0 1,12 @@
# frozen_string_literal: true

class DropFollowRecommendations < ActiveRecord::Migration[7.0]
  def up
    drop_view :follow_recommendations, materialized: true
  end

  def down
    create_view :follow_recommendations, version: 2, materialized: { no_data: true }
    safety_assured { add_index :follow_recommendations, :account_id, unique: true }
  end
end

M db/schema.rb => db/schema.rb +11 -9
@@ 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_14_223300) do
ActiveRecord::Schema[7.0].define(version: 2023_08_18_142253) do
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"



@@ 1334,34 1334,36 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_14_223300) do
  SQL
  add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true

  create_view "follow_recommendations", materialized: true, sql_definition: <<-SQL
  create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
      SELECT t0.account_id,
      sum(t0.rank) AS rank,
      array_agg(t0.reason) AS reason
     FROM ( SELECT account_summaries.account_id,
              ((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
              'most_followed'::text AS reason
             FROM (((follows
             FROM ((follows
               JOIN account_summaries ON ((account_summaries.account_id = follows.target_account_id)))
               JOIN users ON ((users.account_id = follows.account_id)))
               LEFT JOIN follow_recommendation_suppressions ON ((follow_recommendation_suppressions.account_id = follows.target_account_id)))
            WHERE ((users.current_sign_in_at >= (now() - 'P30D'::interval)) AND (account_summaries.sensitive = false) AND (follow_recommendation_suppressions.id IS NULL))
            WHERE ((users.current_sign_in_at >= (now() - 'P30D'::interval)) AND (account_summaries.sensitive = false) AND (NOT (EXISTS ( SELECT 1
                     FROM follow_recommendation_suppressions
                    WHERE (follow_recommendation_suppressions.account_id = follows.target_account_id)))))
            GROUP BY account_summaries.account_id
           HAVING (count(follows.id) >= 5)
          UNION ALL
           SELECT account_summaries.account_id,
              (sum((status_stats.reblogs_count + status_stats.favourites_count)) / (1.0 + sum((status_stats.reblogs_count + status_stats.favourites_count)))) AS rank,
              'most_interactions'::text AS reason
             FROM (((status_stats
             FROM ((status_stats
               JOIN statuses ON ((statuses.id = status_stats.status_id)))
               JOIN account_summaries ON ((account_summaries.account_id = statuses.account_id)))
               LEFT JOIN follow_recommendation_suppressions ON ((follow_recommendation_suppressions.account_id = statuses.account_id)))
            WHERE ((statuses.id >= (((date_part('epoch'::text, (now() - 'P30D'::interval)) * (1000)::double precision))::bigint << 16)) AND (account_summaries.sensitive = false) AND (follow_recommendation_suppressions.id IS NULL))
            WHERE ((statuses.id >= (((date_part('epoch'::text, (now() - 'P30D'::interval)) * (1000)::double precision))::bigint << 16)) AND (account_summaries.sensitive = false) AND (NOT (EXISTS ( SELECT 1
                     FROM follow_recommendation_suppressions
                    WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
            GROUP BY account_summaries.account_id
           HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
    GROUP BY t0.account_id
    ORDER BY (sum(t0.rank)) DESC;
  SQL
  add_index "follow_recommendations", ["account_id"], name: "index_follow_recommendations_on_account_id", unique: true
  add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true

end

A db/views/global_follow_recommendations_v01.sql => db/views/global_follow_recommendations_v01.sql +32 -0
@@ 0,0 1,32 @@
SELECT
  account_id,
  sum(rank) AS rank,
  array_agg(reason) AS reason
FROM (
  SELECT
    account_summaries.account_id AS account_id,
    count(follows.id) / (1.0 + count(follows.id)) AS rank,
    'most_followed' AS reason
  FROM follows
  INNER JOIN account_summaries ON account_summaries.account_id = follows.target_account_id
  INNER JOIN users ON users.account_id = follows.account_id
  WHERE users.current_sign_in_at >= (now() - interval '30 days')
    AND account_summaries.sensitive = 'f'
    AND NOT EXISTS (SELECT 1 FROM follow_recommendation_suppressions WHERE follow_recommendation_suppressions.account_id = follows.target_account_id)
  GROUP BY account_summaries.account_id
  HAVING count(follows.id) >= 5
  UNION ALL
  SELECT account_summaries.account_id AS account_id,
         sum(status_stats.reblogs_count + status_stats.favourites_count) / (1.0 + sum(status_stats.reblogs_count + status_stats.favourites_count)) AS rank,
         'most_interactions' AS reason
  FROM status_stats
  INNER JOIN statuses ON statuses.id = status_stats.status_id
  INNER JOIN account_summaries ON account_summaries.account_id = statuses.account_id
  WHERE statuses.id >= ((date_part('epoch', now() - interval '30 days') * 1000)::bigint << 16)
    AND account_summaries.sensitive = 'f'
    AND NOT EXISTS (SELECT 1 FROM follow_recommendation_suppressions WHERE follow_recommendation_suppressions.account_id = statuses.account_id)
  GROUP BY account_summaries.account_id
  HAVING sum(status_stats.reblogs_count + status_stats.favourites_count) >= 5
) t0
GROUP BY account_id
ORDER BY rank DESC

M lib/http_extensions.rb => lib/http_extensions.rb +3 -5
@@ 2,9 2,7 @@

# Monkey patching until https://github.com/httprb/http/pull/757 is merged
unless HTTP::Request::METHODS.include?(:purge)
  module HTTP
    class Request
      METHODS = METHODS.dup.push(:purge).freeze
    end
  end
  methods = HTTP::Request::METHODS.dup
  HTTP::Request.send(:remove_const, :METHODS)
  HTTP::Request.const_set(:METHODS, methods.push(:purge).freeze)
end

M lib/mastodon/version.rb => lib/mastodon/version.rb +1 -1
@@ 17,7 17,7 @@ module Mastodon
    end

    def flags
      ENV.fetch('MASTODON_VERSION_FLAGS', '-beta1')
      ENV['MASTODON_VERSION_FLAGS'].presence || '-beta2'
    end

    def suffix

M lib/paperclip/transcoder.rb => lib/paperclip/transcoder.rb +4 -4
@@ 40,11 40,11 @@ module Paperclip
        unless eligible_to_passthrough?(metadata)
          @output_options['acodec'] = 'aac'
          @output_options['strict'] = 'experimental'
        end

        if high_vfr?(metadata) && !eligible_to_passthrough?(metadata)
          @output_options['vsync'] = 'vfr'
          @output_options['r'] = @vfr_threshold
          if high_vfr?(metadata)
            @output_options['vsync'] = 'vfr'
            @output_options['r'] = @vfr_threshold
          end
        end
      end


M lib/redis/namespace_extensions.rb => lib/redis/namespace_extensions.rb +2 -2
@@ 2,8 2,8 @@

class Redis
  module NamespaceExtensions
    def exists?(*args, &block)
      call_with_namespace('exists?', *args, &block)
    def exists?(...)
      call_with_namespace('exists?', ...)
    end
  end
end

M package.json => package.json +1 -0
@@ 60,6 60,7 @@
    "babel-plugin-preval": "^5.1.0",
    "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
    "blurhash": "^2.0.5",
    "circular-dependency-plugin": "^5.2.2",
    "classnames": "^2.3.2",
    "cocoon-js-vanilla": "^1.3.0",
    "color-blend": "^4.0.0",

M spec/controllers/api/v1/instances/translation_languages_controller_spec.rb => spec/controllers/api/v1/instances/translation_languages_controller_spec.rb +1 -2
@@ 16,8 16,7 @@ describe Api::V1::Instances::TranslationLanguagesController do
    context 'when a translation service is configured' do
      before do
        service = instance_double(TranslationService::DeepL, languages: { nil => %w(en de), 'en' => ['de'] })
        allow(TranslationService).to receive(:configured?).and_return(true)
        allow(TranslationService).to receive(:configured).and_return(service)
        allow(TranslationService).to receive_messages(configured?: true, configured: service)
      end

      it 'returns language matrix' do

M spec/controllers/api/v1/statuses/translations_controller_spec.rb => spec/controllers/api/v1/statuses/translations_controller_spec.rb +1 -2
@@ 20,8 20,7 @@ describe Api::V1::Statuses::TranslationsController do
      before do
        translation = TranslationService::Translation.new(text: 'Hello')
        service = instance_double(TranslationService::DeepL, translate: [translation])
        allow(TranslationService).to receive(:configured?).and_return(true)
        allow(TranslationService).to receive(:configured).and_return(service)
        allow(TranslationService).to receive_messages(configured?: true, configured: service)
        Rails.cache.write('translation_service/languages', { 'es' => ['en'] })
        post :create, params: { status_id: status.id }
      end

M spec/helpers/admin/filter_helper_spec.rb => spec/helpers/admin/filter_helper_spec.rb +1 -2
@@ 7,8 7,7 @@ describe Admin::FilterHelper do
    params = ActionController::Parameters.new(
      { test: 'test' }
    )
    allow(helper).to receive(:params).and_return(params)
    allow(helper).to receive(:url_for).and_return('/test')
    allow(helper).to receive_messages(params: params, url_for: '/test')
    result = helper.filter_link_to('text', { resolved: true })

    expect(result).to match(/text/)

M spec/helpers/application_helper_spec.rb => spec/helpers/application_helper_spec.rb +1 -4
@@ 31,10 31,7 @@ describe ApplicationHelper do
    context 'with a body class string from a controller' do
      before do
        without_partial_double_verification do
          allow(helper).to receive(:body_class_string).and_return('modal-layout compose-standalone')
          allow(helper).to receive(:current_flavour).and_return('glitch')
          allow(helper).to receive(:current_skin).and_return('default')
          allow(helper).to receive(:current_account).and_return(Fabricate(:account))
          allow(helper).to receive_messages(body_class_string: 'modal-layout compose-standalone', current_flavour: 'glitch', current_skin: 'default', current_account: Fabricate(:account))
        end
      end


M spec/helpers/home_helper_spec.rb => spec/helpers/home_helper_spec.rb +3 -7
@@ 25,8 25,7 @@ RSpec.describe HomeHelper do

      it 'returns a link to the account' do
        without_partial_double_verification do
          allow(helper).to receive(:current_account).and_return(account)
          allow(helper).to receive(:prefers_autoplay?).and_return(false)
          allow(helper).to receive_messages(current_account: account, prefers_autoplay?: false)
          result = helper.account_link_to(account)

          expect(result).to match "@#{account.acct}"


@@ 101,8 100,7 @@ RSpec.describe HomeHelper do

    context 'with open registrations' do
      it 'returns correct sign up message' do
        allow(helper).to receive(:closed_registrations?).and_return(false)
        allow(helper).to receive(:open_registrations?).and_return(true)
        allow(helper).to receive_messages(closed_registrations?: false, open_registrations?: true)
        result = helper.sign_up_message

        expect(result).to eq t('auth.register')


@@ 111,9 109,7 @@ RSpec.describe HomeHelper do

    context 'with approved registrations' do
      it 'returns correct sign up message' do
        allow(helper).to receive(:closed_registrations?).and_return(false)
        allow(helper).to receive(:open_registrations?).and_return(false)
        allow(helper).to receive(:approved_registrations?).and_return(true)
        allow(helper).to receive_messages(closed_registrations?: false, open_registrations?: false, approved_registrations?: true)
        result = helper.sign_up_message

        expect(result).to eq t('auth.apply_for_account')

M spec/lib/admin/system_check/elasticsearch_check_spec.rb => spec/lib/admin/system_check/elasticsearch_check_spec.rb +31 -3
@@ 11,7 11,24 @@ describe Admin::SystemCheck::ElasticsearchCheck do

  describe 'pass?' do
    context 'when chewy is enabled' do
      before { allow(Chewy).to receive(:enabled?).and_return(true) }
      before do
        allow(Chewy).to receive(:enabled?).and_return(true)
        allow(Chewy.client.cluster).to receive(:health).and_return({ 'status' => 'green', 'number_of_nodes' => 1 })
        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,
          InstancesIndex.index_name => InstancesIndex.mappings_hash.deep_stringify_keys,
          TagsIndex.index_name => TagsIndex.mappings_hash.deep_stringify_keys,
        }, get_settings: {
          'chewy_specifications' => {
            'settings' => {
              'index' => {
                'number_of_replicas' => 0,
              },
            },
          },
        })
      end

      context 'when running version is present and high enough' do
        before do


@@ 67,8 84,19 @@ describe Admin::SystemCheck::ElasticsearchCheck do
  end

  describe 'message' do
    before do
      allow(Chewy).to receive(:enabled?).and_return(true)
      allow(Chewy.client.cluster).to receive(:health).and_return({ 'status' => 'green', 'number_of_nodes' => 1 })
      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,
        InstancesIndex.index_name => InstancesIndex.mappings_hash.deep_stringify_keys,
        TagsIndex.index_name => TagsIndex.mappings_hash.deep_stringify_keys,
      })
    end

    context 'when running version is present' do
      before { allow(Chewy.client).to receive(:info).and_return({ 'version' => { 'number' => '999.99.9' } }) }
      before { allow(Chewy.client).to receive(:info).and_return({ 'version' => { 'number' => '1.2.3' } }) }

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


@@ 77,7 105,7 @@ describe Admin::SystemCheck::ElasticsearchCheck do
        check.message

        expect(Admin::SystemCheck::Message).to have_received(:new)
          .with(:elasticsearch_version_check, 'Elasticsearch 999.99.9 is running while 7.x is required')
          .with(:elasticsearch_version_check, 'Elasticsearch 1.2.3 is running while 7.x is required')
      end
    end


M spec/models/report_filter_spec.rb => spec/models/report_filter_spec.rb +1 -2
@@ 23,8 23,7 @@ describe ReportFilter do
    it 'combines filters on Report' do
      filter = described_class.new(account_id: '123', resolved: true, target_account_id: '456')

      allow(Report).to receive(:where).and_return(Report.none)
      allow(Report).to receive(:resolved).and_return(Report.none)
      allow(Report).to receive_messages(where: Report.none, resolved: Report.none)
      filter.results
      expect(Report).to have_received(:where).with(account_id: '123')
      expect(Report).to have_received(:where).with(target_account_id: '456')

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

require 'rails_helper'

RSpec.describe 'Deleting profile images' do
  let(:account) do
    Fabricate(
      :account,
      avatar: fixture_file_upload('avatar.gif', 'image/gif'),
      header: fixture_file_upload('attachment.jpg', 'image/jpeg')
    )
  end
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: account.user.id, scopes: scopes) }
  let(:scopes)  { 'write:accounts' }
  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

  describe 'DELETE /api/v1/profile' do
    before do
      allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async)
    end

    context 'when deleting an avatar' do
      context 'with wrong scope' do
        before do
          delete '/api/v1/profile/avatar', headers: headers
        end

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

      it 'returns http success' do
        delete '/api/v1/profile/avatar', headers: headers

        expect(response).to have_http_status(200)
      end

      it 'deletes the avatar' do
        delete '/api/v1/profile/avatar', headers: headers

        account.reload

        expect(account.avatar).to_not exist
      end

      it 'does not delete the header' do
        delete '/api/v1/profile/avatar', headers: headers

        account.reload

        expect(account.header).to exist
      end

      it 'queues up an account update distribution' do
        delete '/api/v1/profile/avatar', headers: headers

        expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id)
      end
    end

    context 'when deleting a header' do
      context 'with wrong scope' do
        before do
          delete '/api/v1/profile/header', headers: headers
        end

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

      it 'returns http success' do
        delete '/api/v1/profile/header', headers: headers

        expect(response).to have_http_status(200)
      end

      it 'does not delete the avatar' do
        delete '/api/v1/profile/header', headers: headers

        account.reload

        expect(account.avatar).to exist
      end

      it 'deletes the header' do
        delete '/api/v1/profile/header', headers: headers

        account.reload

        expect(account.header).to_not exist
      end

      it 'queues up an account update distribution' do
        delete '/api/v1/profile/header', headers: headers

        expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id)
      end
    end
  end
end

M spec/requests/api/v1/timelines/public_spec.rb => spec/requests/api/v1/timelines/public_spec.rb +7 -0
@@ 60,6 60,13 @@ describe 'Public' do
        it_behaves_like 'a successful request to the public timeline'
      end

      context 'with local and remote params' do
        let(:params) { { local: true, remote: true } }
        let(:expected_statuses) { [local_status, remote_status, media_status] }

        it_behaves_like 'a successful request to the public timeline'
      end

      context 'with only_media param' do
        let(:params) { { only_media: true } }
        let(:expected_statuses) { [media_status] }

M spec/services/suspend_account_service_spec.rb => spec/services/suspend_account_service_spec.rb +1 -2
@@ 10,8 10,7 @@ RSpec.describe SuspendAccountService, type: :service do
    let!(:list)           { Fabricate(:list, account: local_follower) }

    before do
      allow(FeedManager.instance).to receive(:unmerge_from_home).and_return(nil)
      allow(FeedManager.instance).to receive(:unmerge_from_list).and_return(nil)
      allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil)

      local_follower.follow!(account)
      list.accounts << account

M spec/services/translate_status_service_spec.rb => spec/services/translate_status_service_spec.rb +1 -2
@@ 29,8 29,7 @@ RSpec.describe TranslateStatusService, type: :service do
        end
      end

      allow(TranslationService).to receive(:configured?).and_return(true)
      allow(TranslationService).to receive(:configured).and_return(translation_service)
      allow(TranslationService).to receive_messages(configured?: true, configured: translation_service)
    end

    it 'returns translated status content' do

M spec/services/unsuspend_account_service_spec.rb => spec/services/unsuspend_account_service_spec.rb +1 -2
@@ 10,8 10,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
    let!(:list)           { Fabricate(:list, account: local_follower) }

    before do
      allow(FeedManager.instance).to receive(:merge_into_home).and_return(nil)
      allow(FeedManager.instance).to receive(:merge_into_list).and_return(nil)
      allow(FeedManager.instance).to receive_messages(merge_into_home: nil, merge_into_list: nil)

      local_follower.follow!(account)
      list.accounts << account

M spec/views/statuses/show.html.haml_spec.rb => spec/views/statuses/show.html.haml_spec.rb +1 -7
@@ 4,15 4,9 @@ require 'rails_helper'

describe 'statuses/show.html.haml', without_verify_partial_doubles: true do
  before do
    allow(view).to receive(:api_oembed_url).and_return('')
    allow(view).to receive(:show_landing_strip?).and_return(true)
    allow(view).to receive(:site_title).and_return('example site')
    allow(view).to receive(:site_hostname).and_return('example.com')
    allow(view).to receive(:full_asset_url).and_return('//asset.host/image.svg')
    allow(view).to receive_messages(api_oembed_url: '', show_landing_strip?: true, site_title: 'example site', site_hostname: 'example.com', full_asset_url: '//asset.host/image.svg', current_account: nil, single_user_mode?: false)
    allow(view).to receive(:local_time)
    allow(view).to receive(:local_time_ago)
    allow(view).to receive(:current_account).and_return(nil)
    allow(view).to receive(:single_user_mode?).and_return(false)
    assign(:instance_presenter, InstancePresenter.new)
  end


M streaming/index.js => streaming/index.js +5 -0
@@ 110,6 110,11 @@ const pgConfigFromEnv = (env) => {

  if (env.DATABASE_URL) {
    baseConfig = dbUrlToConfig(env.DATABASE_URL);

    // Support overriding the database password in the connection URL
    if (!baseConfig.password && env.DB_PASS) {
      baseConfig.password = env.DB_PASS;
    }
  } else {
    baseConfig = pgConfigs[environment];


M yarn.lock => yarn.lock +227 -192
@@ 1253,10 1253,10 @@
  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.6.2.tgz#1816b5f6948029c5eaacb0703b850ee0cb37d8f8"
  integrity sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==

"@eslint/eslintrc@^2.1.1":
  version "2.1.1"
  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.1.tgz#18d635e24ad35f7276e8a49d135c7d3ca6a46f93"
  integrity sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==
"@eslint/eslintrc@^2.1.2":
  version "2.1.2"
  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396"
  integrity sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==
  dependencies:
    ajv "^6.12.4"
    debug "^4.3.2"


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

"@eslint/js@^8.46.0":
  version "8.46.0"
  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.46.0.tgz#3f7802972e8b6fe3f88ed1aabc74ec596c456db6"
  integrity sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==
"@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==

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


@@ 1755,10 1755,10 @@
  resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71"
  integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==

"@redis/client@1.5.8":
  version "1.5.8"
  resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.5.8.tgz#a375ba7861825bd0d2dc512282b8bff7b98dbcb1"
  integrity sha512-xzElwHIO6rBAqzPeVnCzgvrnBEcFL1P0w8P65VNLRkdVW8rOE58f52hdj0BDgmsdOm4f1EoXPZtH4Fh7M/qUpw==
"@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"


@@ 1779,10 1779,10 @@
  resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.3.tgz#b5a6837522ce9028267fe6f50762a8bcfd2e998b"
  integrity sha512-4Dg1JjvCevdiCBTZqjhKkGoC5/BcB7k9j99kdMnaXFXg8x4eyOIVg9487CMv7/BUVkFLZCaIh8ead9mU15DNng==

"@redis/time-series@1.0.4":
  version "1.0.4"
  resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.4.tgz#af85eb080f6934580e4d3b58046026b6c2b18717"
  integrity sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==
"@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"


@@ 2570,49 2570,48 @@
    "@types/yargs-parser" "*"

"@typescript-eslint/eslint-plugin@^6.0.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.3.0.tgz#e751e148aab7ccaf8a7bfd370f7ce9e6bdd1f3f4"
  integrity sha512-IZYjYZ0ifGSLZbwMqIip/nOamFiWJ9AH+T/GYNZBWkVcyNQOFGtSMoWV7RvY4poYCMZ/4lHzNl796WOSNxmk8A==
  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==
  dependencies:
    "@eslint-community/regexpp" "^4.5.1"
    "@typescript-eslint/scope-manager" "6.3.0"
    "@typescript-eslint/type-utils" "6.3.0"
    "@typescript-eslint/utils" "6.3.0"
    "@typescript-eslint/visitor-keys" "6.3.0"
    "@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"
    debug "^4.3.4"
    graphemer "^1.4.0"
    ignore "^5.2.4"
    natural-compare "^1.4.0"
    natural-compare-lite "^1.4.0"
    semver "^7.5.4"
    ts-api-utils "^1.0.1"

"@typescript-eslint/parser@^6.0.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.3.0.tgz#359684c443f4f848db3c4f14674f544f169c8f46"
  integrity sha512-ibP+y2Gr6p0qsUkhs7InMdXrwldjxZw66wpcQq9/PzAroM45wdwyu81T+7RibNCh8oc0AgrsyCwJByncY0Ongg==
  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.3.0"
    "@typescript-eslint/types" "6.3.0"
    "@typescript-eslint/typescript-estree" "6.3.0"
    "@typescript-eslint/visitor-keys" "6.3.0"
    "@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"
    debug "^4.3.4"

"@typescript-eslint/scope-manager@6.3.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.3.0.tgz#6b74e338c4b88d5e1dfc1a28c570dd5cf8c86b09"
  integrity sha512-WlNFgBEuGu74ahrXzgefiz/QlVb+qg8KDTpknKwR7hMH+lQygWyx0CQFoUmMn1zDkQjTBBIn75IxtWss77iBIQ==
"@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==
  dependencies:
    "@typescript-eslint/types" "6.3.0"
    "@typescript-eslint/visitor-keys" "6.3.0"
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/visitor-keys" "6.4.0"

"@typescript-eslint/type-utils@6.3.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.3.0.tgz#3bf89ccd36621ddec1b7f8246afe467c67adc247"
  integrity sha512-7Oj+1ox1T2Yc8PKpBvOKWhoI/4rWFd1j7FA/rPE0lbBPXTKjdbtC+7Ev0SeBjEKkIhKWVeZSP+mR7y1Db1CdfQ==
"@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==
  dependencies:
    "@typescript-eslint/typescript-estree" "6.3.0"
    "@typescript-eslint/utils" "6.3.0"
    "@typescript-eslint/typescript-estree" "6.4.0"
    "@typescript-eslint/utils" "6.4.0"
    debug "^4.3.4"
    ts-api-utils "^1.0.1"



@@ 2621,10 2620,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.3.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.3.0.tgz#84517f1427923e714b8418981e493b6635ab4c9d"
  integrity sha512-K6TZOvfVyc7MO9j60MkRNWyFSf86IbOatTKGrpTQnzarDZPYPVy0oe3myTMq7VjhfsUAbNUW8I5s+2lZvtx1gg==
"@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/typescript-estree@5.59.0":
  version "5.59.0"


@@ 2639,30 2638,30 @@
    semver "^7.3.7"
    tsutils "^3.21.0"

"@typescript-eslint/typescript-estree@6.3.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.3.0.tgz#20e1e10e2f51cdb9e19a2751215cac92c003643c"
  integrity sha512-Xh4NVDaC4eYKY4O3QGPuQNp5NxBAlEvNQYOqJquR2MePNxO11E5K3t5x4M4Mx53IZvtpW+mBxIT0s274fLUocg==
"@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==
  dependencies:
    "@typescript-eslint/types" "6.3.0"
    "@typescript-eslint/visitor-keys" "6.3.0"
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/visitor-keys" "6.4.0"
    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.3.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.3.0.tgz#0898c5e374372c2092ca1b979ea7ee9cc020ce84"
  integrity sha512-hLLg3BZE07XHnpzglNBG8P/IXq/ZVXraEbgY7FM0Cnc1ehM8RMdn9mat3LubJ3KBeYXXPxV1nugWbQPjGeJk6Q==
"@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==
  dependencies:
    "@eslint-community/eslint-utils" "^4.4.0"
    "@types/json-schema" "^7.0.12"
    "@types/semver" "^7.5.0"
    "@typescript-eslint/scope-manager" "6.3.0"
    "@typescript-eslint/types" "6.3.0"
    "@typescript-eslint/typescript-estree" "6.3.0"
    "@typescript-eslint/scope-manager" "6.4.0"
    "@typescript-eslint/types" "6.4.0"
    "@typescript-eslint/typescript-estree" "6.4.0"
    semver "^7.5.4"

"@typescript-eslint/visitor-keys@5.59.0":


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

"@typescript-eslint/visitor-keys@6.3.0":
  version "6.3.0"
  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.3.0.tgz#8d09aa3e389ae0971426124c155ac289afbe450a"
  integrity sha512-kEhRRj7HnvaSjux1J9+7dBen15CdWmDnwrpyiHsFX6Qx2iW5LOBUgNefOFeh2PjWPlNwN8TOn6+4eBU3J/gupw==
"@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==
  dependencies:
    "@typescript-eslint/types" "6.3.0"
    "@typescript-eslint/types" "6.4.0"
    eslint-visitor-keys "^3.4.1"

"@webassemblyjs/ast@1.9.0":


@@ 3270,6 3269,13 @@ async@^3.2.3:
  resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
  integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==

asynciterator.prototype@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz#8c5df0514936cdd133604dfcc9d3fb93f09b2b62"
  integrity sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==
  dependencies:
    has-symbols "^1.0.3"

asynckit@^0.4.0:
  version "0.4.0"
  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"


@@ 3997,6 4003,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
    inherits "^2.0.1"
    safe-buffer "^5.0.1"

circular-dependency-plugin@^5.2.2:
  version "5.2.2"
  resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.2.2.tgz#39e836079db1d3cf2f988dc48c5188a44058b600"
  integrity sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ==

cjs-module-lexer@^1.0.0:
  version "1.2.3"
  resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107"


@@ 4306,9 4317,9 @@ core-js@^2.5.0:
  integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==

core-js@^3.30.2:
  version "3.32.0"
  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.32.0.tgz#7643d353d899747ab1f8b03d2803b0312a0fb3b6"
  integrity sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww==
  version "3.32.1"
  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.32.1.tgz#a7d8736a3ed9dd05940c3c4ff32c591bb735be77"
  integrity sha512-lqufgNn9NLnESg5mQeYsxQP5w7wrViSj0jr/kv6ECQiByzQkrn1MKvV0L3acttpDqfQrHLwr2KCMgX5b8X+lyQ==

core-util-is@~1.0.0:
  version "1.0.3"


@@ 5160,7 5171,7 @@ error-stack-parser@^2.0.6:
  dependencies:
    stackframe "^1.3.4"

es-abstract@^1.17.2, es-abstract@^1.21.2:
es-abstract@^1.17.2, es-abstract@^1.19.0, es-abstract@^1.20.4, es-abstract@^1.21.2, es-abstract@^1.21.3:
  version "1.22.1"
  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.1.tgz#8b4e5fc5cefd7f1660f0f8e1a52900dfbc9d9ccc"
  integrity sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==


@@ 5205,46 5216,6 @@ es-abstract@^1.17.2, es-abstract@^1.21.2:
    unbox-primitive "^1.0.2"
    which-typed-array "^1.1.10"

es-abstract@^1.19.0, es-abstract@^1.20.4:
  version "1.21.2"
  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.21.2.tgz#a56b9695322c8a185dc25975aa3b8ec31d0e7eff"
  integrity sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==
  dependencies:
    array-buffer-byte-length "^1.0.0"
    available-typed-arrays "^1.0.5"
    call-bind "^1.0.2"
    es-set-tostringtag "^2.0.1"
    es-to-primitive "^1.2.1"
    function.prototype.name "^1.1.5"
    get-intrinsic "^1.2.0"
    get-symbol-description "^1.0.0"
    globalthis "^1.0.3"
    gopd "^1.0.1"
    has "^1.0.3"
    has-property-descriptors "^1.0.0"
    has-proto "^1.0.1"
    has-symbols "^1.0.3"
    internal-slot "^1.0.5"
    is-array-buffer "^3.0.2"
    is-callable "^1.2.7"
    is-negative-zero "^2.0.2"
    is-regex "^1.1.4"
    is-shared-array-buffer "^1.0.2"
    is-string "^1.0.7"
    is-typed-array "^1.1.10"
    is-weakref "^1.0.2"
    object-inspect "^1.12.3"
    object-keys "^1.1.1"
    object.assign "^4.1.4"
    regexp.prototype.flags "^1.4.3"
    safe-regex-test "^1.0.0"
    string.prototype.trim "^1.2.7"
    string.prototype.trimend "^1.0.6"
    string.prototype.trimstart "^1.0.6"
    typed-array-length "^1.0.4"
    unbox-primitive "^1.0.2"
    which-typed-array "^1.1.9"

es-array-method-boxes-properly@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e"


@@ 5265,6 5236,26 @@ es-get-iterator@^1.1.3:
    isarray "^2.0.5"
    stop-iteration-iterator "^1.0.0"

es-iterator-helpers@^1.0.12:
  version "1.0.13"
  resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.13.tgz#72101046ffc19baf9996adc70e6177a26e6e8084"
  integrity sha512-LK3VGwzvaPWobO8xzXXGRUOGw8Dcjyfk62CsY/wfHN75CwsJPbuypOYJxK6g5RyEL8YDjIWcl6jgd8foO6mmrA==
  dependencies:
    asynciterator.prototype "^1.0.0"
    call-bind "^1.0.2"
    define-properties "^1.2.0"
    es-abstract "^1.21.3"
    es-set-tostringtag "^2.0.1"
    function-bind "^1.1.1"
    get-intrinsic "^1.2.1"
    globalthis "^1.0.3"
    has-property-descriptors "^1.0.0"
    has-proto "^1.0.1"
    has-symbols "^1.0.3"
    internal-slot "^1.0.5"
    iterator.prototype "^1.1.0"
    safe-array-concat "^1.0.0"

es-set-tostringtag@^2.0.1:
  version "2.0.1"
  resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8"


@@ 5332,13 5323,13 @@ eslint-config-prettier@^9.0.0:
  integrity sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==

eslint-import-resolver-node@^0.3.7:
  version "0.3.7"
  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz#83b375187d412324a1963d84fa664377a23eb4d7"
  integrity sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==
  version "0.3.9"
  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac"
  integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==
  dependencies:
    debug "^3.2.7"
    is-core-module "^2.11.0"
    resolve "^1.22.1"
    is-core-module "^2.13.0"
    resolve "^1.22.4"

eslint-import-resolver-typescript@^3.5.5:
  version "3.6.0"


@@ 5378,9 5369,9 @@ eslint-plugin-formatjs@^4.10.1:
    unicode-emoji-utils "^1.1.1"

eslint-plugin-import@~2.28.0:
  version "2.28.0"
  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz#8d66d6925117b06c4018d491ae84469eb3cb1005"
  integrity sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==
  version "2.28.1"
  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz#63b8b5b3c409bfc75ebaf8fb206b07ab435482c4"
  integrity sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==
  dependencies:
    array-includes "^3.1.6"
    array.prototype.findlastindex "^1.2.2"


@@ 5391,20 5382,19 @@ eslint-plugin-import@~2.28.0:
    eslint-import-resolver-node "^0.3.7"
    eslint-module-utils "^2.8.0"
    has "^1.0.3"
    is-core-module "^2.12.1"
    is-core-module "^2.13.0"
    is-glob "^4.0.3"
    minimatch "^3.1.2"
    object.fromentries "^2.0.6"
    object.groupby "^1.0.0"
    object.values "^1.1.6"
    resolve "^1.22.3"
    semver "^6.3.1"
    tsconfig-paths "^3.14.2"

eslint-plugin-jsdoc@^46.1.0:
  version "46.4.6"
  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.4.6.tgz#5226461eda61b5920297cbe02c3b17bc9423cf0b"
  integrity sha512-z4SWYnJfOqftZI+b3RM9AtWL1vF/sLWE/LlO9yOKDof9yN2+n3zOdOJTGX/pRE/xnPsooOLG2Rq6e4d+XW3lNw==
  version "46.5.0"
  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.5.0.tgz#02e7945701a01fab76e7ced850d4d1eea63c23c0"
  integrity sha512-aulXdA4I1dyWpzyS1Nh/GNoS6PavzeucxEapnMR4JUERowWvaEk2Y4A5irpHAcdXtBBHLVe8WIhdXNjoAlGQgA==
  dependencies:
    "@es-joy/jsdoccomment" "~0.40.1"
    are-docs-informative "^0.0.2"


@@ 5457,14 5447,15 @@ eslint-plugin-react-hooks@^4.6.0:
  integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==

eslint-plugin-react@~7.33.0:
  version "7.33.1"
  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.33.1.tgz#bc27cccf860ae45413a4a4150bf0977345c1ceab"
  integrity sha512-L093k0WAMvr6VhNwReB8VgOq5s2LesZmrpPdKz/kZElQDzqS7G7+DnKoqT+w4JwuiGeAhAvHO0fvy0Eyk4ejDA==
  version "7.33.2"
  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz#69ee09443ffc583927eafe86ffebb470ee737608"
  integrity sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==
  dependencies:
    array-includes "^3.1.6"
    array.prototype.flatmap "^1.3.1"
    array.prototype.tosorted "^1.1.1"
    doctrine "^2.1.0"
    es-iterator-helpers "^1.0.12"
    estraverse "^5.3.0"
    jsx-ast-utils "^2.4.1 || ^3.0.0"
    minimatch "^3.1.2"


@@ 5493,20 5484,20 @@ eslint-scope@^7.2.2:
    esrecurse "^4.3.0"
    estraverse "^5.2.0"

eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.2:
  version "3.4.2"
  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz#8c2095440eca8c933bedcadf16fefa44dbe9ba5f"
  integrity sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==
eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3:
  version "3.4.3"
  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
  integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==

eslint@^8.41.0:
  version "8.46.0"
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.46.0.tgz#a06a0ff6974e53e643acc42d1dcf2e7f797b3552"
  integrity sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==
  version "8.47.0"
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.47.0.tgz#c95f9b935463fb4fad7005e626c7621052e90806"
  integrity sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==
  dependencies:
    "@eslint-community/eslint-utils" "^4.2.0"
    "@eslint-community/regexpp" "^4.6.1"
    "@eslint/eslintrc" "^2.1.1"
    "@eslint/js" "^8.46.0"
    "@eslint/eslintrc" "^2.1.2"
    "@eslint/js" "^8.47.0"
    "@humanwhocodes/config-array" "^0.11.10"
    "@humanwhocodes/module-importer" "^1.0.1"
    "@nodelib/fs.walk" "^1.2.8"


@@ 5517,7 5508,7 @@ eslint@^8.41.0:
    doctrine "^3.0.0"
    escape-string-regexp "^4.0.0"
    eslint-scope "^7.2.2"
    eslint-visitor-keys "^3.4.2"
    eslint-visitor-keys "^3.4.3"
    espree "^9.6.1"
    esquery "^1.4.2"
    esutils "^2.0.2"


@@ 6272,9 6263,9 @@ globals@^11.1.0:
  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==

globals@^13.19.0:
  version "13.20.0"
  resolved "https://registry.yarnpkg.com/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82"
  integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==
  version "13.21.0"
  resolved "https://registry.yarnpkg.com/globals/-/globals-13.21.0.tgz#163aae12f34ef502f5153cfbdd3600f36c63c571"
  integrity sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==
  dependencies:
    type-fest "^0.20.2"



@@ 6667,9 6658,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.2"
  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.2.tgz#f89d910f8dfb6e15c03b2cae2faaf8c1f66455fe"
  integrity sha512-oGXzbEDem9OOpDWZu88jGiYCvIsLHMvGw+8OXlpsvTFvIQplQbjg1B1cvKg8f7Hoch6+NGjpPsH1Fr+Mc2D1aA==
  version "4.3.3"
  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.3.tgz#8934ff6826d996a7642c8dc4b46e694dd19561e3"
  integrity sha512-808ZFYMsIRAjLAu5xkKo0TsbY9LBy9H5MazTKIEHerNkg0ymgilGfBPMR/3G7d/ihGmuK2Hw8S1izY2d3kd3wA==

import-fresh@^3.2.1:
  version "3.3.0"


@@ 6858,6 6849,13 @@ is-arrayish@^0.2.1:
  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
  integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==

is-async-function@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.0.0.tgz#8e4418efd3e5d3a6ebb0164c05ef5afb69aa9646"
  integrity sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==
  dependencies:
    has-tostringtag "^1.0.0"

is-bigint@^1.0.1:
  version "1.0.4"
  resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"


@@ 6899,20 6897,13 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:
  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
  integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==

is-core-module@^2.11.0, is-core-module@^2.12.1, is-core-module@^2.13.0, is-core-module@^2.5.0:
is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.5.0, is-core-module@^2.9.0:
  version "2.13.0"
  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
  integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
  dependencies:
    has "^1.0.3"

is-core-module@^2.9.0:
  version "2.12.1"
  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.1.tgz#0c0b6885b6f80011c71541ce15c8d66cf5a4f9fd"
  integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==
  dependencies:
    has "^1.0.3"

is-data-descriptor@^0.1.4:
  version "0.1.4"
  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"


@@ 6984,6 6975,13 @@ is-extglob@^2.1.0, is-extglob@^2.1.1:
  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==

is-finalizationregistry@^1.0.2:
  version "1.0.2"
  resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz#c8749b65f17c133313e661b1289b95ad3dbd62e6"
  integrity sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==
  dependencies:
    call-bind "^1.0.2"

is-fullwidth-code-point@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"


@@ 7004,6 7002,13 @@ is-generator-fn@^2.0.0:
  resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118"
  integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==

is-generator-function@^1.0.10:
  version "1.0.10"
  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
  integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
  dependencies:
    has-tostringtag "^1.0.0"

is-glob@^3.1.0:
  version "3.1.0"
  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"


@@ 7165,15 7170,11 @@ is-symbol@^1.0.2, is-symbol@^1.0.3:
    has-symbols "^1.0.2"

is-typed-array@^1.1.10, is-typed-array@^1.1.9:
  version "1.1.10"
  resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.10.tgz#36a5b5cb4189b575d1a3e4b08536bfb485801e3f"
  integrity sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==
  version "1.1.12"
  resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.12.tgz#d0bab5686ef4a76f7a73097b95470ab199c57d4a"
  integrity sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==
  dependencies:
    available-typed-arrays "^1.0.5"
    call-bind "^1.0.2"
    for-each "^0.3.3"
    gopd "^1.0.1"
    has-tostringtag "^1.0.0"
    which-typed-array "^1.1.11"

is-url@^1.2.4:
  version "1.2.4"


@@ 7291,6 7292,17 @@ istanbul-reports@^3.1.3:
    html-escaper "^2.0.0"
    istanbul-lib-report "^3.0.0"

iterator.prototype@^1.1.0:
  version "1.1.0"
  resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.0.tgz#690c88b043d821f783843aaf725d7ac3b62e3b46"
  integrity sha512-rjuhAk1AJ1fssphHD0IFV6TWL40CwRZ53FrztKx43yk2v6rguBYsY4Bj1VU4HmoMmKwZUlx7mfnhDf9cOp4YTw==
  dependencies:
    define-properties "^1.1.4"
    get-intrinsic "^1.1.3"
    has-symbols "^1.0.3"
    has-tostringtag "^1.0.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"


@@ 7871,7 7883,17 @@ jsonpointer@^5.0.0:
  resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559"
  integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==

"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.3:
"jsx-ast-utils@^2.4.1 || ^3.0.0":
  version "3.3.5"
  resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"
  integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==
  dependencies:
    array-includes "^3.1.6"
    array.prototype.flat "^1.3.1"
    object.assign "^4.1.4"
    object.values "^1.1.6"

jsx-ast-utils@^3.3.3:
  version "3.3.3"
  resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz#76b3e6e6cece5c69d49a5792c3d01bd1a0cdc7ea"
  integrity sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==


@@ 8562,11 8584,6 @@ nanomatch@^1.2.9:
    snapdragon "^0.8.1"
    to-regex "^3.0.1"

natural-compare-lite@^1.4.0:
  version "1.4.0"
  resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
  integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==

natural-compare@^1.4.0:
  version "1.4.0"
  resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"


@@ 10084,9 10101,9 @@ react-test-renderer@^18.2.0:
    scheduler "^0.23.0"

react-textarea-autosize@*, react-textarea-autosize@^8.4.1:
  version "8.5.2"
  resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.2.tgz#6421df2b5b50b9ca8c5e96fd31be688ea7fa2f9d"
  integrity sha512-uOkyjkEl0ByEK21eCJMHDGBAAd/BoFQBawYK5XItjAmCTeSbjxghd8qnt7nzsLYzidjnoObu6M26xts0YGKsGg==
  version "8.5.3"
  resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz#d1e9fe760178413891484847d3378706052dd409"
  integrity sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==
  dependencies:
    "@babel/runtime" "^7.20.13"
    use-composed-ref "^1.3.0"


@@ 10200,16 10217,16 @@ redent@^4.0.0:
    strip-indent "^4.0.0"

redis@^4.6.5:
  version "4.6.7"
  resolved "https://registry.yarnpkg.com/redis/-/redis-4.6.7.tgz#c73123ad0b572776223f172ec78185adb72a6b57"
  integrity sha512-KrkuNJNpCwRm5vFJh0tteMxW8SaUzkm5fBH7eL5hd/D0fAkzvapxbfGPP/r+4JAXdQuX7nebsBkBqA2RHB7Usw==
  version "4.6.8"
  resolved "https://registry.yarnpkg.com/redis/-/redis-4.6.8.tgz#54c5992e8a5ba512506fe9f53142cadc405547e7"
  integrity sha512-S7qNkPUYrsofQ0ztWlTHSaK0Qqfl1y+WMIxrzeAGNG+9iUZB4HGeBgkHxE6uJJ6iXrkvLd1RVJ2nvu6H1sAzfQ==
  dependencies:
    "@redis/bloom" "1.2.0"
    "@redis/client" "1.5.8"
    "@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.4"
    "@redis/time-series" "1.0.5"

redux-immutable@^4.0.0:
  version "4.0.0"


@@ 10228,6 10245,18 @@ redux@^4.0.0, redux@^4.2.1:
  dependencies:
    "@babel/runtime" "^7.9.2"

reflect.getprototypeof@^1.0.3:
  version "1.0.3"
  resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.3.tgz#2738fd896fcc3477ffbd4190b40c2458026b6928"
  integrity sha512-TTAOZpkJ2YLxl7mVHWrNo3iDMEkYlva/kgFcXndqMgbo/AZUmmavEkdXV+hXtE4P8xdyEKRzalaFqZVuwIk/Nw==
  dependencies:
    call-bind "^1.0.2"
    define-properties "^1.1.4"
    es-abstract "^1.20.4"
    get-intrinsic "^1.1.1"
    globalthis "^1.0.3"
    which-builtin-type "^1.1.3"

regenerate-unicode-properties@^10.1.0:
  version "10.1.0"
  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c"


@@ 10405,7 10434,7 @@ resolve.exports@^2.0.0:
  resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800"
  integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==

resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.3:
resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.4:
  version "1.22.4"
  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.4.tgz#1dc40df46554cdaf8948a486a10f6ba1e2026c34"
  integrity sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==


@@ 10414,7 10443,7 @@ resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.3:
    path-parse "^1.0.7"
    supports-preserve-symlinks-flag "^1.0.0"

resolve@^1.19.0, resolve@^1.22.1:
resolve@^1.19.0:
  version "1.22.2"
  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f"
  integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==


@@ 10585,9 10614,9 @@ sass-loader@^10.2.0:
    semver "^7.3.2"

sass@^1.62.1:
  version "1.65.1"
  resolved "https://registry.yarnpkg.com/sass/-/sass-1.65.1.tgz#8f283b0c26335a88246a448d22e1342ba2ea1432"
  integrity sha512-9DINwtHmA41SEd36eVPQ9BJKpn7eKDQmUHmpI0y5Zv2Rcorrh0zS+cFrt050hdNbmmCNKTW3hV5mWfuegNRsEA==
  version "1.66.1"
  resolved "https://registry.yarnpkg.com/sass/-/sass-1.66.1.tgz#04b51c4671e4650aa393740e66a4e58b44d055b1"
  integrity sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==
  dependencies:
    chokidar ">=3.0.0 <4.0.0"
    immutable "^4.0.0"


@@ 11806,9 11835,9 @@ trim-newlines@^4.0.2:
  integrity sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==

ts-api-utils@^1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.1.tgz#8144e811d44c749cd65b2da305a032510774452d"
  integrity sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==
  version "1.0.2"
  resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.2.tgz#7c094f753b6705ee4faee25c3c684ade52d66d99"
  integrity sha512-Cbu4nIqnEdd+THNEsBdkolnOXhg0I8XteoHaEKgvsxpsbWda4IsUut2c187HxywQCvveojow0Dgw/amxtSKVkQ==

tsconfig-paths@^3.14.2:
  version "3.14.2"


@@ 12520,6 12549,24 @@ which-boxed-primitive@^1.0.2:
    is-string "^1.0.5"
    is-symbol "^1.0.3"

which-builtin-type@^1.1.3:
  version "1.1.3"
  resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.3.tgz#b1b8443707cc58b6e9bf98d32110ff0c2cbd029b"
  integrity sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==
  dependencies:
    function.prototype.name "^1.1.5"
    has-tostringtag "^1.0.0"
    is-async-function "^2.0.0"
    is-date-object "^1.0.5"
    is-finalizationregistry "^1.0.2"
    is-generator-function "^1.0.10"
    is-regex "^1.1.4"
    is-weakref "^1.0.2"
    isarray "^2.0.5"
    which-boxed-primitive "^1.0.2"
    which-collection "^1.0.1"
    which-typed-array "^1.1.9"

which-collection@^1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906"


@@ 12535,7 12582,7 @@ which-module@^2.0.0:
  resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
  integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==

which-typed-array@^1.1.10:
which-typed-array@^1.1.10, which-typed-array@^1.1.11, which-typed-array@^1.1.9:
  version "1.1.11"
  resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a"
  integrity sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==


@@ 12546,18 12593,6 @@ which-typed-array@^1.1.10:
    gopd "^1.0.1"
    has-tostringtag "^1.0.0"

which-typed-array@^1.1.9:
  version "1.1.9"
  resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6"
  integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==
  dependencies:
    available-typed-arrays "^1.0.5"
    call-bind "^1.0.2"
    for-each "^0.3.3"
    gopd "^1.0.1"
    has-tostringtag "^1.0.0"
    is-typed-array "^1.1.10"

which@^1.2.14, which@^1.2.9, which@^1.3.1:
  version "1.3.1"
  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"