diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 0000000000..df8e92b247 --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,59 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: true +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_complete_foreign_keys: false +:show_foreign_keys: false +:show_indexes: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:active_admin: false +:command: +:debug: false +:hide_default_column_types: '' +:hide_limit_column_types: 'integer,boolean' +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: + - app/models +:require: [] +:root_dir: + - '' + +:show_check_constraints: false diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 5c7263c874..705d26e0ab 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -69,7 +69,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.6.1 + image: libretranslate/libretranslate:v1.6.2 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.env.production.sample b/.env.production.sample index 0b458a1aa9..3dd66abae4 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -45,6 +45,17 @@ ES_PASS=password SECRET_KEY_BASE= OTP_SECRET= +# Encryption secrets +# ------------------ +# Must be available (and set to same values) for all server processes +# These are private/secret values, do not share outside hosting environment +# Use `bin/rails db:encryption:init` to generate fresh secrets +# Do not change these secrets once in use, as this would cause data loss and other issues +# ------------------ +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= + # Web Push # -------- # Generate with `bundle exec rails mastodon:webpush:generate_vapid_key` diff --git a/.eslintrc.js b/.eslintrc.js index b6e4253e61..93ff1d7b59 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,7 +64,6 @@ module.exports = defineConfig({ 'indent': ['error', 2], 'jsx-quotes': ['error', 'prefer-single'], 'semi': ['error', 'always'], - 'no-case-declarations': 'off', 'no-catch-shadow': 'error', 'no-console': [ 'warn', diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml index 20e27d103c..f897a7d7da 100644 --- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml @@ -1,6 +1,7 @@ name: Bug Report (Web Interface) -description: If you are using Mastodon's web interface and something is not working as expected -labels: [bug, 'status/to triage', 'area/web interface'] +description: There is a problem using Mastodon's web interface. +labels: ['status/to triage', 'area/web interface'] +type: Bug body: - type: markdown attributes: @@ -47,8 +48,8 @@ body: attributes: label: Mastodon version description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 validations: required: true - type: input @@ -56,7 +57,7 @@ body: label: Browser name and version description: | What browser are you using when getting this bug? Please specify the version as well. - placeholder: Firefox 105.0.3 + placeholder: Firefox 131.0.0 validations: required: true - type: input @@ -64,7 +65,7 @@ body: label: Operating system description: | What OS are you running? Please specify the version as well. - placeholder: macOS 13.4.1 + placeholder: macOS 15.0.1 validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml index 49d5f57209..a66f5c1076 100644 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -1,7 +1,8 @@ name: Bug Report (server / API) description: | - If something is not working as expected, but is not from using the web interface. -labels: [bug, 'status/to triage'] + There is a problem with the HTTP server, REST API, ActivityPub interaction, etc. +labels: ['status/to triage'] +type: 'Bug' body: - type: markdown attributes: @@ -48,8 +49,8 @@ body: attributes: label: Mastodon version description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 validations: required: false - type: textarea @@ -59,7 +60,7 @@ body: Any additional technical details you may have, like logs or error traces value: | If this is happening on your own Mastodon server, please fill out those: - - Ruby version: (from `ruby --version`, eg. v3.1.2) - - Node.js version: (from `node --version`, eg. v18.16.0) + - Ruby version: (from `ruby --version`, eg. v3.3.5) + - Node.js version: (from `node --version`, eg. v20.18.0) validations: required: false diff --git a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml new file mode 100644 index 0000000000..eeb74b160b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -0,0 +1,74 @@ +name: Deployment troubleshooting +description: | + You are a server administrator and you are encountering a technical issue during installation, upgrade or operations of Mastodon. +labels: ['status/to triage'] +type: 'Troubleshooting' +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: true + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 + validations: + required: false + - type: textarea + attributes: + label: Environment + description: | + Details about your environment, like how Mastodon is deployed, if containers are used, version numbers, etc. + value: | + Please at least include those informations: + - Operating system: (eg. Ubuntu 22.04) + - Ruby version: (from `ruby --version`, eg. v3.3.5) + - Node.js version: (from `node --version`, eg. v20.18.0) + validations: + required: false + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have, like logs or error traces + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3.feature_request.yml b/.github/ISSUE_TEMPLATE/4.feature_request.yml similarity index 96% rename from .github/ISSUE_TEMPLATE/3.feature_request.yml rename to .github/ISSUE_TEMPLATE/4.feature_request.yml index 2cabcf61e0..7146b4f8a3 100644 --- a/.github/ISSUE_TEMPLATE/3.feature_request.yml +++ b/.github/ISSUE_TEMPLATE/4.feature_request.yml @@ -1,6 +1,6 @@ name: Feature Request description: I have a suggestion -labels: [suggestion] +type: Suggestion body: - type: markdown attributes: diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 03a0f5bf37..6204319a63 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -92,6 +92,7 @@ jobs: build-args: | MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} + SOURCE_COMMIT=${{ github.sha }} platforms: ${{ inputs.platforms }} provenance: false builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index 72baed5121..d3bc8e5df8 100644 --- a/.github/workflows/build-push-pr.yml +++ b/.github/workflows/build-push-pr.yml @@ -21,9 +21,11 @@ jobs: uses: actions/checkout@v4 - id: version_vars run: | - echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT + echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT + echo mastodon_short_sha=$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT outputs: metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }} + short_sha: ${{ steps.version_vars.outputs.mastodon_short_sha }} build-image: needs: compute-suffix @@ -39,6 +41,7 @@ jobs: latest=auto tags: | type=ref,event=pr + type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }} secrets: inherit build-image-streaming: @@ -55,4 +58,5 @@ jobs: latest=auto tags: | type=ref,event=pr + type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }} secrets: inherit diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index 3f0bef32ac..da9a458282 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -23,7 +23,7 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.2.') }} + latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index 2341d6e67f..fa28d28f74 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -36,4 +36,4 @@ jobs: bundler-cache: true - name: Run bundler-audit - run: bundle exec bundler-audit check --update + run: bin/bundler-audit check --update diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index 5a1c051966..4f87f0fe5f 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -18,7 +18,7 @@ permissions: jobs: check-i18n: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -35,18 +35,18 @@ jobs: git diff --exit-code - name: Check locale file normalization - run: bundle exec i18n-tasks check-normalized + run: bin/i18n-tasks check-normalized - name: Check for unused strings - run: bundle exec i18n-tasks unused + run: bin/i18n-tasks unused - name: Check for missing strings in English YML run: | - bundle exec i18n-tasks add-missing -l en + bin/i18n-tasks add-missing -l en git diff --exit-code - name: Check for wrong string interpolations - run: bundle exec i18n-tasks check-consistent-interpolations + run: bin/i18n-tasks check-consistent-interpolations - name: Check that all required locale files exist - run: bundle exec rake repo:check_locales_files + run: bin/rake repo:check_locales_files diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml new file mode 100644 index 0000000000..ef28258cca --- /dev/null +++ b/.github/workflows/crowdin-download-stable.yml @@ -0,0 +1,69 @@ +name: Crowdin / Download translations (stable branches) +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + download-translations-stable: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Increase Git http.postBuffer + # This is needed due to a bug in Ubuntu's cURL version? + # See https://github.com/orgs/community/discussions/55820 + run: | + git config --global http.version HTTP/1.1 + git config --global http.postBuffer 157286400 + + # Download the translation files from Crowdin + - name: crowdin action + uses: crowdin/github-action@v2 + with: + upload_sources: false + upload_translations: false + download_translations: true + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} + push_translations: false + create_pull_request: false + env: + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # As the files are extracted from a Docker container, they belong to root:root + # We need to fix this before the next steps + - name: Fix file permissions + run: sudo chown -R runner:docker . + + # This is needed to run the normalize step + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Run i18n normalize task + run: bin/i18n-tasks normalize + + # Create or update the pull request + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7.0.5 + with: + commit-message: 'New Crowdin translations' + title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' + author: 'GitHub Actions ' + body: | + New Crowdin translations, automated with GitHub Actions + + See `.github/workflows/crowdin-download.yml` + + This PR will be updated every day with new translations. + + Due to a limitation in GitHub Actions, checks are not running on this PR without manual action. + If you want to run the checks, then close and re-open it. + branch: i18n/crowdin/translations-${{ github.base_ref || github.ref_name }} + base: ${{ github.base_ref || github.ref_name }} + labels: i18n diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index f1817b3e9a..e9b909b9e0 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -48,11 +48,11 @@ jobs: uses: ./.github/actions/setup-ruby - name: Run i18n normalize task - run: bundle exec i18n-tasks normalize + run: bin/i18n-tasks normalize # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v7.0.1 + uses: peter-evans/create-pull-request@v7.0.5 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations (automated)' diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index b7a0a2b819..4f4d917d15 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -1,7 +1,6 @@ name: Crowdin / Upload translations on: - merge_group: push: branches: - 'main' @@ -31,7 +30,7 @@ jobs: upload_sources: true upload_translations: false download_translations: false - crowdin_branch_name: main + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} env: CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index a1a9e99c90..499be2010a 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -43,4 +43,4 @@ jobs: - name: Run haml-lint run: | echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" - bundle exec haml-lint --reporter github + bin/haml-lint --reporter github diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 6a0e67c58e..306191fb8e 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -12,6 +12,7 @@ on: - '**/*.rb' - '.github/workflows/test-migrations.yml' - 'lib/tasks/tests.rake' + - 'lib/tasks/db.rake' pull_request: paths: @@ -32,6 +33,8 @@ jobs: postgres: - 14-alpine - 15-alpine + - 16-alpine + - 17-alpine services: postgres: @@ -88,6 +91,11 @@ jobs: bin/rails db:drop bin/rails db:create SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database + + # Migrate up to v4.2.0 breakpoint + bin/rails db:migrate VERSION=20230907150100 + + # Migrate the rest SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate bin/rails db:migrate bin/rails tests:migrations:check_database diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 3da53c1ae8..770cd72a1b 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -124,7 +124,6 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' - '.ruby-version' steps: @@ -143,7 +142,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg libpam-dev + additional-system-dependencies: ffmpeg imagemagick libpam-dev - name: Load database schema run: | @@ -226,7 +225,6 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' - '.ruby-version' steps: @@ -245,7 +243,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg libpam-dev libyaml-dev + additional-system-dependencies: ffmpeg libpam-dev - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' @@ -305,7 +303,6 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' - '.ruby-version' @@ -325,7 +322,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg + additional-system-dependencies: ffmpeg imagemagick - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -422,7 +419,6 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' - '.ruby-version' search-image: @@ -445,7 +441,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg + additional-system-dependencies: ffmpeg imagemagick - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.nvmrc b/.nvmrc index 65da8ce391..35d2d08ea1 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.17 +22.12 diff --git a/.rubocop.yml b/.rubocop.yml index 965f56f3e7..ebeed6ea49 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,7 +8,7 @@ AllCops: - lib/mastodon/migration_helpers.rb ExtraDetails: true NewCops: enable - TargetRubyVersion: 3.1 # Oldest supported ruby version + TargetRubyVersion: 3.2 # Oldest supported ruby version inherit_from: - .rubocop/layout.yml diff --git a/.ruby-version b/.ruby-version index fa7adc7ac7..9c25013dbb 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.5 +3.3.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1e0bfcf7..ebc166a48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,49 @@ All notable changes to this project will be documented in this file. -## [4.3.0] - UNRELEASED +## [4.3.1] - 2024-10-21 + +### Added + +- Add more explicit explanations about author attribution and `fediverse:creator` (#32383 by @ClearlyClaire) +- Add ability to group follow notifications in WebUI, can be disabled in the column settings (#32520 by @renchap) +- Add back a 6 hours mute duration option (#32522 by @renchap) +- Add note about not changing ActiveRecord encryption secrets once they are set (#32413, #32476, #32512, and #32537 by @ClearlyClaire and @mjankowski) + +### Changed + +- Change translation feature to translate to selected regional variant (e.g. pt-BR) if available (#32428 by @c960657) + +### Removed + +- Remove ability to get embed code for remote posts (#32578 by @ClearlyClaire)\ + Getting the embed code is only reliable for local posts.\ + It never worked for non-Mastodon servers, and stopped working correctly with the changes made in 4.3.0.\ + We have therefore decided to remove the menu entry while we investigate solutions. + +### Fixed + +- Fix follow recommendation moderation page default language when using regional variant (#32580 by @ClearlyClaire) +- Fix column-settings spacing in local timeline in advanced view (#32567 by @lindwurm) +- Fix broken i18n in text welcome mailer tags area (#32571 by @mjankowski) +- Fix missing or incorrect cache-control headers for Streaming server (#32551 by @ThisIsMissEm) +- Fix only the first paragraph being displayed in some notifications (#32348 by @ClearlyClaire) +- Fix reblog icons on account media view (#32506 by @tribela) +- Fix Content-Security-Policy not allowing OpenStack SWIFT object storage URI (#32439 by @kenkiku1021) +- Fix back arrow pointing to the incorrect direction in RTL languages (#32485 by @renchap) +- Fix streaming server using `REDIS_USERNAME` instead of `REDIS_USER` (#32493 by @ThisIsMissEm) +- Fix follow recommendation carrousel scrolling on RTL layouts (#32462 and #32505 by @ClearlyClaire) +- Fix follow recommendation suppressions not applying immediately (#32392 by @ClearlyClaire) +- Fix language of push notifications (#32415 by @ClearlyClaire) +- Fix mute duration not being shown in list of muted accounts in web UI (#32388 by @ClearlyClaire) +- Fix “Mark every notification as read” not updating the read marker if scrolled down (#32385 by @ClearlyClaire) +- Fix “Mention” appearing for otherwise filtered posts (#32356 by @ClearlyClaire) +- Fix notification requests from suspended accounts still being listed (#32354 by @ClearlyClaire) +- Fix list edition modal styling (#32358 and #32367 by @ClearlyClaire and @vmstan) +- Fix 4 columns barely not fitting on 1920px screen (#32361 by @ClearlyClaire) +- Fix icon alignment in applications list (#32293 by @mjankowski) + +## [4.3.0] - 2024-10-08 The following changelog entries focus on changes visible to users, administrators, client developers or federated software developers, but there has also been a lot of code modernization, refactoring, and tooling work, in particular by @mjankowski. @@ -10,12 +52,13 @@ The following changelog entries focus on changes visible to users, administrator - **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\ This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared. -- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 by @ClearlyClaire) +- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx)) +- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 and #32241 by @ClearlyClaire) - Update dependencies ### Added -- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610 and #31929 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\ +- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610, #31929, #32089, #32085, #32243, #32179 and #32254 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\ Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\ This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\ As part of this, the visual design of the entire notifications feature has been revamped.\ @@ -25,9 +68,9 @@ The following changelog entries focus on changes visible to users, administrator - `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped - `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group - `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts - - `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group + - `POST /api/v2/notifications/:group_key/dismiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group - `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count -- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, and #31723 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\ +- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\ The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\ You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\ Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\ @@ -60,13 +103,13 @@ The following changelog entries focus on changes visible to users, administrator - **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\ You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\ This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link -- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, and #31900 by @Gargron and @oneiros)\ +- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, #31900 and #32188 by @Gargron, @mjankowski and @oneiros)\ This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\ Articles hosted outside the fediverse can indicate a fediverse author with a meta tag: ```html ``` - On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors\ + On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors \ Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\ This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1 - **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\ @@ -76,7 +119,11 @@ The following changelog entries focus on changes visible to users, administrator Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation. - **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\ See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel -- Add ability to reorder uploaded media before posting in web UI (#28456 by @Gargron) +- **Add ability to reorder uploaded media before posting in web UI** (#28456 and #32093 by @Gargron) +- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire) +- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron) +- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron) +- Add reblogs and favourites counts to statuses in ActivityPub (#32007 by @Gargron) - Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm) - Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\ This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon @@ -122,14 +169,14 @@ The following changelog entries focus on changes visible to users, administrator - Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap) - Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc) - Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan) -- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, and #30858 by @ClearlyClaire, @Gargron, and @mjankowski)\ +- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, #30858 and #32104 by @ClearlyClaire, @Gargron, and @mjankowski)\ Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\ This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\ This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future. - Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm) - Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2) - Add active animation to header settings button (#30221, #30307, and #30388 by @daudix) -- Add OpenTelemetry instrumentation (#30130, #30322, #30353, and #30350 by @julianocosta89, @renchap, and @robbkidd)\ +- Add OpenTelemetry instrumentation (#30130, #30322, #30353, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\ See https://docs.joinmastodon.org/admin/config/#otel for documentation - Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\ This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index @@ -138,7 +185,6 @@ The following changelog entries focus on changes visible to users, administrator - Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm) - Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire) - Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm) -- Add global Regexp timeout (#31928 by @ClearlyClaire) - Add the role ID to the badge component (#29707 by @renchap) - Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski) - Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski) @@ -146,10 +192,12 @@ The following changelog entries focus on changes visible to users, administrator - Add groundwork for annual reports for accounts (#28693 by @Gargron)\ This lays the groundwork for a “year-in-review”/“wrapped” style report for local users, but is currently not in use. - Add notification email on invalid second authenticator (#28822 by @ClearlyClaire) +- Add date of account deletion in list of accounts in the admin interface (#25640 by @tribela) - Add new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem) - Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\ This makes the S3 batch size configurable through the `S3_BATCH_DELETE_LIMIT` environment variable (defaults to 1000), and adds some retry logic, configurable through the `S3_BATCH_DELETE_RETRY` environment variable (defaults to 3). - Add VAPID public key to instance serializer (#28006 by @ThisIsMissEm) +- Add support for serving JRD `/.well-known/host-meta.json` in addition to XRD host-meta (#32206 by @c960657) - Add `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543) - Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus) - Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire) @@ -164,18 +212,18 @@ The following changelog entries focus on changes visible to users, administrator ### Changed -- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, and #31525 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\ +- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, #31525, #32153, and #32201 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\ This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\ In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state. -- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, and #31889 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\ +- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, #31889 and #32033 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\ The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\ As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”. - **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\ The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\ They also have a more modern and consistent design, along with other confirmation modals in the application. -- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan) +- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @renchap, and @vmstan) - **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron) -- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, and #29879 by @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\ +- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, #29879, #32073 and #32132 by @c960657, @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\ All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients. - **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\ This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\ @@ -188,10 +236,17 @@ The following changelog entries focus on changes visible to users, administrator Administrators may need to update their setup accordingly. - Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron) - Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros) -- Change embedded posts to use web UI (#31766 by @Gargron) +- Change embedded posts to use web UI (#31766, #32135 and #32271 by @Gargron) - Change inner borders in media galleries in web UI (#31852 by @Gargron) -- Change design of hide media button in web UI (#31807 by @Gargron) +- Change design of media attachments and profile media tab in web UI (#31807, #32048, #31967, #32217, #32224 and #32257 by @ClearlyClaire and @Gargron) - Change labels on thread indicators in web UI (#31806 by @Gargron) +- Change label of "Data export" menu item in settings interface (#32099 by @c960657) +- Change responsive break points on navigation panel in web UI (#32034 by @Gargron) +- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski) +- Change OAuth authorization prompt to not refer to apps as “third-party” (#32005 by @Gargron) +- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire) +- Change zoom icon in web UI (#29683 by @Gargron) +- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap) - Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm) - Change width of columns in advanced web UI (#31762 by @Gargron) - Change design of unread conversations in web UI (#31763 by @Gargron) @@ -254,6 +309,7 @@ The following changelog entries focus on changes visible to users, administrator ### Removed +- Remove unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski) - Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski) - Remove `CacheBuster` default options (#30718 by @mjankowski) - Remove home marker updates from the Web UI (#22721 by @davbeck)\ @@ -269,9 +325,22 @@ The following changelog entries focus on changes visible to users, administrator - Fix log out from user menu not working on Safari (#31402 by @renchap) - Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela) - Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski) +- Fix error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire) +- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire) +- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron) +- Fix unresolvable mentions sometimes preventing processing incoming posts (#29215 by @tribela and @ClearlyClaire) +- Fix too many requests caused by relationship look-ups in web UI (#32042 by @Gargron) +- Fix links for reblogs in moderation interface (#31979 by @ClearlyClaire) +- Fix the appearance of avatars when they do not load (#31966 and #32270 by @Gargron and @renchap) +- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657) - Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil) - Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire) - Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77) +- Fix wrapping in dashboard quick access buttons (#32043 by @renchap) +- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski) +- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski) +- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap) +- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath) - Fix cutoff of instance name in sign-up form (#30598 by @oneiros) - Fix invalid date searches returning 503 errors (#31526 by @notchairmk) - Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657) @@ -285,10 +354,12 @@ The following changelog entries focus on changes visible to users, administrator - Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm) - Fix right-to-left text in preview cards (#30930 by @ClearlyClaire) - Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski) -- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, and #31445 by @valtlai and @vmstan) +- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, #31445, #32091, #32147 and #32137 by @ClearlyClaire, @mjankowski, @valtlai and @vmstan) +- Fix editing description of media uploads with custom thumbnails (#32221 by @ClearlyClaire) - Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire) - Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire) - Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers) +- Fix unneeded requests to blocked domains when receiving relayed signed activities from them (#31161 by @ClearlyClaire) - Fix logo pushing header buttons out of view on certain conditions in mobile layout (#29787 by @ClearlyClaire) - Fix notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire) - Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski) @@ -298,6 +369,7 @@ The following changelog entries focus on changes visible to users, administrator - Fix full date display not respecting the locale 12/24h format (#29448 by @renchap) - Fix filters title and keywords overflow (#29396 by @GeopJr) - Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon) +- Fix navigation item active highlight for some paths (#32159 by @mjankowski) - Fix “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen) - Fix modal container bounds (#29185 by @nico3333fr) - Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire) @@ -327,7 +399,7 @@ The following changelog entries focus on changes visible to users, administrator - Fix empty environment variables not using default nil value (#27400 by @renchap) - Fix language sorting in settings (#27158 by @gunchleoc) -## |4.2.11] - 2024-08-16 +## [4.2.11] - 2024-08-16 ### Added diff --git a/Dockerfile b/Dockerfile index c7c02d9b46..d80a4e1555 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.9 +# syntax=docker/dockerfile:1.12 # This file is designed for production server deployment, not local development work # For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker @@ -12,10 +12,10 @@ ARG BUILDPLATFORM=${BUILDPLATFORM} # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.3.5" +ARG RUBY_VERSION="3.3.6" # # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # renovate: datasource=node-version depName=node -ARG NODE_MAJOR_VERSION="20" +ARG NODE_MAJOR_VERSION="22" # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] ARG DEBIAN_VERSION="bookworm" # Node image to use for base image based on combined variables (ex: 20-bookworm-slim) @@ -29,6 +29,8 @@ FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby ARG MASTODON_VERSION_PRERELEASE="" # Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"] ARG MASTODON_VERSION_METADATA="" +# Will be available as Mastodon::Version.source_commit +ARG SOURCE_COMMIT="" # Allow Ruby on Rails to serve static files # See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files @@ -45,30 +47,31 @@ ARG GID="991" # Apply Mastodon build options based on options above ENV \ -# Apply Mastodon version information + # Apply Mastodon version information MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \ -# Apply Mastodon static files and YJIT options + SOURCE_COMMIT="${SOURCE_COMMIT}" \ + # Apply Mastodon static files and YJIT options RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \ RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \ -# Apply timezone + # Apply timezone TZ=${TZ} ENV \ -# Configure the IP to bind Mastodon to when serving traffic + # Configure the IP to bind Mastodon to when serving traffic BIND="0.0.0.0" \ -# Use production settings for Yarn, Node and related nodejs based tools + # Use production settings for Yarn, Node and related nodejs based tools NODE_ENV="production" \ -# Use production settings for Ruby on Rails + # Use production settings for Ruby on Rails RAILS_ENV="production" \ -# Add Ruby and Mastodon installation to the PATH + # Add Ruby and Mastodon installation to the PATH DEBIAN_FRONTEND="noninteractive" \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ -# Optimize jemalloc 5.x performance + # Optimize jemalloc 5.x performance MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \ -# Enable libvips, should not be changed + # Enable libvips, should not be changed MASTODON_USE_LIBVIPS=true \ -# Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes + # Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs # Set default shell used for running commands @@ -79,14 +82,14 @@ ARG TARGETPLATFORM RUN echo "Target platform is $TARGETPLATFORM" RUN \ -# Remove automatic apt cache Docker cleanup scripts + # Remove automatic apt cache Docker cleanup scripts rm -f /etc/apt/apt.conf.d/docker-clean; \ -# Sets timezone + # Sets timezone echo "${TZ}" > /etc/localtime; \ -# Creates mastodon user/group and sets home directory + # Creates mastodon user/group and sets home directory groupadd -g "${GID}" mastodon; \ useradd -l -u "${UID}" -g "${GID}" -m -d /opt/mastodon mastodon; \ -# Creates /mastodon symlink to /opt/mastodon + # Creates /mastodon symlink to /opt/mastodon ln -s /opt/mastodon /mastodon; # Set /opt/mastodon as working directory @@ -94,28 +97,28 @@ WORKDIR /opt/mastodon # hadolint ignore=DL3008,DL3005 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Apt update & upgrade to check for security updates to Debian image + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Apt update & upgrade to check for security updates to Debian image apt-get update; \ apt-get dist-upgrade -yq; \ -# Install jemalloc, curl and other necessary components + # Install jemalloc, curl and other necessary components apt-get install -y --no-install-recommends \ - curl \ - file \ - libjemalloc2 \ - patchelf \ - procps \ - tini \ - tzdata \ - wget \ + curl \ + file \ + libjemalloc2 \ + patchelf \ + procps \ + tini \ + tzdata \ + wget \ ; \ -# Patch Ruby to use jemalloc + # Patch Ruby to use jemalloc patchelf --add-needed libjemalloc.so.2 /usr/local/bin/ruby; \ -# Discard patchelf after use + # Discard patchelf after use apt-get purge -y \ - patchelf \ + patchelf \ ; # Create temporary build layer from base image @@ -132,56 +135,56 @@ ARG TARGETPLATFORM # hadolint ignore=DL3008 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Install build tools and bundler dependencies from APT + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Install build tools and bundler dependencies from APT apt-get install -y --no-install-recommends \ - autoconf \ - automake \ - build-essential \ - cmake \ - git \ - libgdbm-dev \ - libglib2.0-dev \ - libgmp-dev \ - libicu-dev \ - libidn-dev \ - libpq-dev \ - libssl-dev \ - libtool \ - meson \ - nasm \ - pkg-config \ - shared-mime-info \ - xz-utils \ - # libvips components - libcgif-dev \ - libexif-dev \ - libexpat1-dev \ - libgirepository1.0-dev \ - libheif-dev \ - libimagequant-dev \ - libjpeg62-turbo-dev \ - liblcms2-dev \ - liborc-dev \ - libspng-dev \ - libtiff-dev \ - libwebp-dev \ + autoconf \ + automake \ + build-essential \ + cmake \ + git \ + libgdbm-dev \ + libglib2.0-dev \ + libgmp-dev \ + libicu-dev \ + libidn-dev \ + libpq-dev \ + libssl-dev \ + libtool \ + meson \ + nasm \ + pkg-config \ + shared-mime-info \ + xz-utils \ + # libvips components + libcgif-dev \ + libexif-dev \ + libexpat1-dev \ + libgirepository1.0-dev \ + libheif-dev \ + libimagequant-dev \ + libjpeg62-turbo-dev \ + liblcms2-dev \ + liborc-dev \ + libspng-dev \ + libtiff-dev \ + libwebp-dev \ # ffmpeg components - libdav1d-dev \ - liblzma-dev \ - libmp3lame-dev \ - libopus-dev \ - libsnappy-dev \ - libvorbis-dev \ - libvpx-dev \ - libx264-dev \ - libx265-dev \ + libdav1d-dev \ + liblzma-dev \ + libmp3lame-dev \ + libopus-dev \ + libsnappy-dev \ + libvorbis-dev \ + libvpx-dev \ + libx264-dev \ + libx265-dev \ ; RUN \ -# Configure Corepack + # Configure Corepack rm /usr/local/bin/yarn*; \ corepack enable; \ corepack prepare --activate; @@ -191,7 +194,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.15.3 +ARG VIPS_VERSION=8.16.0 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download @@ -214,7 +217,7 @@ FROM build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg -ARG FFMPEG_VERSION=7.0.2 +ARG FFMPEG_VERSION=7.1 # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"] ARG FFMPEG_URL=https://ffmpeg.org/releases @@ -228,28 +231,28 @@ WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION} # Configure and compile ffmpeg RUN \ ./configure \ - --prefix=/usr/local/ffmpeg \ - --toolchain=hardened \ - --disable-debug \ - --disable-devices \ - --disable-doc \ - --disable-ffplay \ - --disable-network \ - --disable-static \ - --enable-ffmpeg \ - --enable-ffprobe \ - --enable-gpl \ - --enable-libdav1d \ - --enable-libmp3lame \ - --enable-libopus \ - --enable-libsnappy \ - --enable-libvorbis \ - --enable-libvpx \ - --enable-libwebp \ - --enable-libx264 \ - --enable-libx265 \ - --enable-shared \ - --enable-version3 \ + --prefix=/usr/local/ffmpeg \ + --toolchain=hardened \ + --disable-debug \ + --disable-devices \ + --disable-doc \ + --disable-ffplay \ + --disable-network \ + --disable-static \ + --enable-ffmpeg \ + --enable-ffprobe \ + --enable-gpl \ + --enable-libdav1d \ + --enable-libmp3lame \ + --enable-libopus \ + --enable-libsnappy \ + --enable-libvorbis \ + --enable-libvpx \ + --enable-libwebp \ + --enable-libx264 \ + --enable-libx265 \ + --enable-shared \ + --enable-version3 \ ; \ make -j$(nproc); \ make install; @@ -263,17 +266,17 @@ ARG TARGETPLATFORM COPY Gemfile* /opt/mastodon/ RUN \ -# Mount Ruby Gem caches ---mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ -# Configure bundle to prevent changes to Gemfile and Gemfile.lock + # Mount Ruby Gem caches + --mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ + # Configure bundle to prevent changes to Gemfile and Gemfile.lock bundle config set --global frozen "true"; \ -# Configure bundle to not cache downloaded Gems + # Configure bundle to not cache downloaded Gems bundle config set --global cache_all "false"; \ -# Configure bundle to only process production Gems + # Configure bundle to only process production Gems bundle config set --local without "development test"; \ -# Configure bundle to not warn about root user + # Configure bundle to not warn about root user bundle config set silence_root_warning "true"; \ -# Download and install required Gems + # Download and install required Gems bundle install -j"$(nproc)"; # Create temporary node specific build layer from build layer @@ -288,9 +291,9 @@ COPY .yarn /opt/mastodon/.yarn # hadolint ignore=DL3008 RUN \ ---mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ ---mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ -# Install Node packages + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Install Node packages yarn workspaces focus --production @mastodon/mastodon; # Create temporary assets build layer from build layer @@ -311,10 +314,10 @@ ARG TARGETPLATFORM RUN \ ldconfig; \ -# Use Ruby on Rails to create Mastodon assets + # Use Ruby on Rails to create Mastodon assets SECRET_KEY_BASE_DUMMY=1 \ bundle exec rails assets:precompile; \ -# Cleanup temporary files + # Cleanup temporary files rm -fr /opt/mastodon/tmp; # Prep final Mastodon Ruby layer @@ -324,49 +327,49 @@ ARG TARGETPLATFORM # hadolint ignore=DL3008 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Mount Corepack and Yarn caches from Docker buildx caches ---mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ ---mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ -# Apt update install non-dev versions of necessary components + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Mount Corepack and Yarn caches from Docker buildx caches + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Apt update install non-dev versions of necessary components apt-get install -y --no-install-recommends \ - libexpat1 \ - libglib2.0-0 \ - libicu72 \ - libidn12 \ - libpq5 \ - libreadline8 \ - libssl3 \ - libyaml-0-2 \ + libexpat1 \ + libglib2.0-0 \ + libicu72 \ + libidn12 \ + libpq5 \ + libreadline8 \ + libssl3 \ + libyaml-0-2 \ # libvips components - libcgif0 \ - libexif12 \ - libheif1 \ - libimagequant0 \ - libjpeg62-turbo \ - liblcms2-2 \ - liborc-0.4-0 \ - libspng0 \ - libtiff6 \ - libwebp7 \ - libwebpdemux2 \ - libwebpmux3 \ + libcgif0 \ + libexif12 \ + libheif1 \ + libimagequant0 \ + libjpeg62-turbo \ + liblcms2-2 \ + liborc-0.4-0 \ + libspng0 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ # ffmpeg components - libdav1d6 \ - libmp3lame0 \ - libopencore-amrnb0 \ - libopencore-amrwb0 \ - libopus0 \ - libsnappy1v5 \ - libtheora0 \ - libvorbis0a \ - libvorbisenc2 \ - libvorbisfile3 \ - libvpx7 \ - libx264-164 \ - libx265-199 \ + libdav1d6 \ + libmp3lame0 \ + libopencore-amrnb0 \ + libopencore-amrwb0 \ + libopus0 \ + libsnappy1v5 \ + libtheora0 \ + libvorbis0a \ + libvorbisenc2 \ + libvorbisfile3 \ + libvpx7 \ + libx264-164 \ + libx265-199 \ ; # Copy Mastodon sources into final layer @@ -386,7 +389,7 @@ COPY --from=ffmpeg /usr/local/ffmpeg/lib /usr/local/lib RUN \ ldconfig; \ -# Smoketest media processors + # Smoketest media processors vips -v; \ ffmpeg -version; \ ffprobe -version; @@ -396,10 +399,10 @@ RUN \ bundle exec bootsnap precompile --gemfile app/ lib/; RUN \ -# Pre-create and chown system volume to Mastodon user + # Pre-create and chown system volume to Mastodon user mkdir -p /opt/mastodon/public/system; \ chown mastodon:mastodon /opt/mastodon/public/system; \ -# Set Mastodon user as owner of tmp folder + # Set Mastodon user as owner of tmp folder chown -R mastodon:mastodon /opt/mastodon/tmp; # Set the running user for resulting container diff --git a/Gemfile b/Gemfile index bcb19421ab..6abb075c1c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,12 +1,12 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 3.1.0' +ruby '>= 3.2.0' gem 'propshaft' gem 'puma', '~> 6.3' gem 'rack', '~> 2.2.7' -gem 'rails', '~> 7.1.1' +gem 'rails', '~> 7.2.0' gem 'thor', '~> 1.2' gem 'dotenv' @@ -16,16 +16,16 @@ gem 'pghero' gem 'aws-sdk-s3', '~> 1.123', require: false gem 'blurhash', '~> 0.1' -gem 'fog-core', '<= 2.5.0' +gem 'fog-core', '<= 2.6.0' gem 'fog-openstack', '~> 1.0', require: false +gem 'jd-paperclip-azure', '~> 3.0', require: false gem 'kt-paperclip', '~> 7.2' -gem 'md-paperclip-azure', '~> 2.2', require: false gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' gem 'bootsnap', '~> 1.18.0', require: false -gem 'browser', '< 6' # https://github.com/fnando/browser/issues/543 +gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' gem 'devise', '~> 4.9' @@ -47,13 +47,14 @@ gem 'color_diff', '~> 0.1' gem 'csv', '~> 3.2' gem 'discard', '~> 1.2' gem 'doorkeeper', '~> 5.6' +gem 'faraday-httpclient' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'hiredis', '~> 0.6' gem 'htmlentities', '~> 4.3' gem 'http', '~> 5.2.0' gem 'http_accept_language', '~> 2.1' -gem 'httplog', '~> 1.7.0' +gem 'httplog', '~> 1.7.0', require: false gem 'i18n' gem 'idn-ruby', require: 'idn' gem 'inline_svg' @@ -61,7 +62,8 @@ gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' -gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' +gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar' +gem 'mutex_m' gem 'nokogiri', '~> 1.15' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' @@ -111,8 +113,8 @@ group :opentelemetry do gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false - gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.33.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false @@ -170,7 +172,7 @@ group :development do gem 'rubocop-rspec_rails', require: false # Annotates modules with schema - gem 'annotate', '~> 3.2' + gem 'annotaterb', '~> 4.13' # Enhanced error message pages for development gem 'better_errors', '~> 2.9' @@ -220,7 +222,7 @@ gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' -gem 'net-http', '~> 0.4.0' +gem 'net-http', '~> 0.5.0' gem 'rubyzip', '~> 2.3' gem 'hcaptcha', '~> 7.1' diff --git a/Gemfile.lock b/Gemfile.lock index ed217ca146..8cbceae214 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,122 +10,111 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.4) - actionpack (= 7.1.4) - activesupport (= 7.1.4) + actioncable (7.2.2) + actionpack (= 7.2.2) + activesupport (= 7.2.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.4) - actionpack (= 7.1.4) - activejob (= 7.1.4) - activerecord (= 7.1.4) - activestorage (= 7.1.4) - activesupport (= 7.1.4) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.4) - actionpack (= 7.1.4) - actionview (= 7.1.4) - activejob (= 7.1.4) - activesupport (= 7.1.4) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.2) + actionpack (= 7.2.2) + activejob (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) + mail (>= 2.8.0) + actionmailer (7.2.2) + actionpack (= 7.2.2) + actionview (= 7.2.2) + activejob (= 7.2.2) + activesupport (= 7.2.2) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.4) - actionview (= 7.1.4) - activesupport (= 7.1.4) + actionpack (7.2.2) + actionview (= 7.2.2) + activesupport (= 7.2.2) nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.2) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.4) - actionpack (= 7.1.4) - activerecord (= 7.1.4) - activestorage (= 7.1.4) - activesupport (= 7.1.4) + useragent (~> 0.16) + actiontext (7.2.2) + actionpack (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.4) - activesupport (= 7.1.4) + actionview (7.2.2) + activesupport (= 7.2.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_model_serializers (0.10.14) + active_model_serializers (0.10.15) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.1.4) - activesupport (= 7.1.4) + activejob (7.2.2) + activesupport (= 7.2.2) globalid (>= 0.3.6) - activemodel (7.1.4) - activesupport (= 7.1.4) - activerecord (7.1.4) - activemodel (= 7.1.4) - activesupport (= 7.1.4) + activemodel (7.2.2) + activesupport (= 7.2.2) + activerecord (7.2.2) + activemodel (= 7.2.2) + activesupport (= 7.2.2) timeout (>= 0.4.0) - activestorage (7.1.4) - actionpack (= 7.1.4) - activejob (= 7.1.4) - activerecord (= 7.1.4) - activesupport (= 7.1.4) + activestorage (7.2.2) + actionpack (= 7.2.2) + activejob (= 7.2.2) + activerecord (= 7.2.2) + activesupport (= 7.2.2) marcel (~> 1.0) - activesupport (7.1.4) + activesupport (7.2.2) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) + annotaterb (4.13.0) ast (2.4.2) attr_required (1.0.2) - awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.978.0) - aws-sdk-core (3.209.0) + aws-partitions (1.1017.0) + aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.94.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-kms (1.96.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.166.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-s3 (1.176.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.0) + aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) - azure-storage-blob (2.0.3) - azure-storage-common (~> 2.0) - nokogiri (~> 1, >= 1.10.8) - azure-storage-common (2.0.4) - faraday (~> 1.0) - faraday_middleware (~> 1.0, >= 1.0.0.rc1) - net-http-persistent (~> 4.0) - nokogiri (~> 1, >= 1.10.8) + azure-blob (0.5.3) + rexml base64 (0.2.0) bcp47_spec (0.2.1) bcrypt (3.1.20) + benchmark (0.4.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -134,12 +123,12 @@ GEM bindata (2.5.0) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) - blurhash (0.1.7) + blurhash (0.1.8) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.1) + brakeman (6.2.2) racc - browser (5.3.1) + browser (6.2.0) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) redis (>= 1.0, < 6) @@ -179,14 +168,14 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.19.0) + css_parser (1.19.1) addressable csv (3.3.0) database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.3.4) + date (3.4.0) debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) @@ -197,20 +186,20 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (6.0.0) - activesupport (~> 7.0) + devise-two-factor (6.1.0) + activesupport (>= 7.0, < 8.1) devise (~> 4.0) - railties (~> 7.0) + railties (>= 7.0, < 8.1) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.5.1) - discard (1.3.0) - activerecord (>= 4.2, < 8) + discard (1.4.0) + activerecord (>= 4.2, < 9.0) docile (1.4.1) domain_name (0.6.20240107) - doorkeeper (5.7.1) + doorkeeper (5.8.0) railties (>= 5) dotenv (3.1.4) drb (2.2.1) @@ -231,38 +220,21 @@ GEM erubi (1.13.0) et-orbi (1.2.11) tzinfo - excon (0.111.0) + excon (0.112.0) fabrication (2.31.0) - faker (3.4.2) + faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (1.10.3) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) - faraday (~> 1.0) + faraday (2.12.0) + faraday-net_http (>= 2.0, < 3.4) + json + logger + faraday-httpclient (2.0.1) + httpclient (>= 2.2) + faraday-net_http (3.3.0) + net-http fast_blank (1.0.1) fastimage (2.3.1) - ffi (1.16.3) + ffi (1.17.0) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake @@ -301,7 +273,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.58.0) + haml_lint (0.59.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -347,11 +319,15 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.7.2) - irb (1.14.0) + irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) + jd-paperclip-azure (3.0.0) + addressable (~> 2.5) + azure-blob (~> 0.5.2) + hashie (~> 5.0) jmespath (1.6.2) - json (2.7.2) + json (2.8.1) json-canonicalization (1.0.0) json-jwt (1.15.3.1) activesupport (>= 4.2) @@ -366,13 +342,15 @@ GEM rack (>= 2.2, < 4) rdf (~> 3.3) rexml (~> 3.2) - json-ld-preloaded (3.3.0) + json-ld-preloaded (3.3.1) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (5.0.0) + json-schema (5.1.1) addressable (~> 2.8) + bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.7.1) + jwt (2.9.3) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -412,7 +390,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.22.0) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -424,26 +402,20 @@ GEM mario-redis-lock (1.2.1) redis (>= 3.0.5) matrix (0.4.2) - md-paperclip-azure (2.2.0) - addressable (~> 2.5) - azure-storage-blob (~> 2.0.1) - hashie (~> 5.0) memory_profiler (1.1.0) - mime-types (3.5.2) + mime-types (3.6.0) + logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.0820) + mime-types-data (3.2024.1105) mini_mime (1.1.5) - mini_portile2 (2.8.7) - minitest (5.25.1) - msgpack (1.7.2) + mini_portile2 (2.8.8) + minitest (5.25.2) + msgpack (1.7.5) multi_json (1.15.0) - multipart-post (2.4.1) - mutex_m (0.2.0) - net-http (0.4.1) + mutex_m (0.3.0) + net-http (0.5.0) uri - net-http-persistent (4.0.2) - connection_pool (~> 2.2) - net-imap (0.4.15) + net-imap (0.5.1) date net-protocol net-ldap (0.19.0) @@ -453,11 +425,11 @@ GEM timeout net-smtp (0.5.0) net-protocol - nio4r (2.7.3) - nokogiri (1.16.7) + nio4r (2.7.4) + nokogiri (1.16.8) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.6) + oj (3.16.7) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.2) @@ -494,34 +466,35 @@ GEM opentelemetry-api (1.4.0) opentelemetry-common (0.21.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.29.0) + opentelemetry-exporter-otlp (0.29.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.2) opentelemetry-semantic_conventions - opentelemetry-helpers-sql-obfuscation (0.2.0) + opentelemetry-helpers-sql-obfuscation (0.2.1) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.1.0) + opentelemetry-instrumentation-action_mailer (0.2.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_support (~> 0.1) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-action_pack (0.9.0) + opentelemetry-instrumentation-action_pack (0.10.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.7.2) + opentelemetry-instrumentation-action_view (0.7.3) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.1) + opentelemetry-instrumentation-active_support (~> 0.6) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_job (0.7.7) + opentelemetry-instrumentation-active_job (0.7.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_model_serializers (0.20.2) + opentelemetry-instrumentation-active_model_serializers (0.20.3) opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (>= 0.6.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_record (0.7.3) + opentelemetry-instrumentation-active_record (0.8.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-active_support (0.6.0) @@ -534,35 +507,35 @@ GEM opentelemetry-instrumentation-concurrent_ruby (0.21.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.4) + opentelemetry-instrumentation-excon (0.22.5) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.6) + opentelemetry-instrumentation-faraday (0.24.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.4) + opentelemetry-instrumentation-http (0.23.5) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.7) + opentelemetry-instrumentation-http_client (0.22.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.7) + opentelemetry-instrumentation-net_http (0.22.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.29.0) + opentelemetry-instrumentation-pg (0.29.1) opentelemetry-api (~> 1.0) opentelemetry-helpers-sql-obfuscation opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (0.24.6) + opentelemetry-instrumentation-rack (0.25.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.31.2) + opentelemetry-instrumentation-rails (0.33.1) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.1.0) - opentelemetry-instrumentation-action_pack (~> 0.9.0) + opentelemetry-instrumentation-action_mailer (~> 0.2.0) + opentelemetry-instrumentation-action_pack (~> 0.10.0) opentelemetry-instrumentation-action_view (~> 0.7.0) opentelemetry-instrumentation-active_job (~> 0.7.0) - opentelemetry-instrumentation-active_record (~> 0.7.0) + opentelemetry-instrumentation-active_record (~> 0.8.0) opentelemetry-instrumentation-active_support (~> 0.6.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-redis (0.25.7) @@ -573,7 +546,7 @@ GEM opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-registry (0.3.1) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.5.0) + opentelemetry-sdk (1.6.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) @@ -581,17 +554,17 @@ GEM opentelemetry-semantic_conventions (1.10.1) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) - ostruct (0.6.0) + ostruct (0.6.1) ox (2.14.18) parallel (1.26.3) - parser (3.3.5.0) + parser (3.3.6.0) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.8) - pghero (3.6.0) + pg (1.5.9) + pghero (3.6.1) activerecord (>= 6.1) premailer (1.27.0) addressable @@ -601,21 +574,21 @@ GEM actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) - propshaft (1.0.0) + propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.1.2) + psych (5.2.0) stringio public_suffix (6.0.1) - puma (6.4.3) + puma (6.5.0) nio4r (~> 2.0) pundit (2.4.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.9) + rack (2.2.10) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -638,20 +611,20 @@ GEM rackup (1.0.0) rack (< 3) webrick - rails (7.1.4) - actioncable (= 7.1.4) - actionmailbox (= 7.1.4) - actionmailer (= 7.1.4) - actionpack (= 7.1.4) - actiontext (= 7.1.4) - actionview (= 7.1.4) - activejob (= 7.1.4) - activemodel (= 7.1.4) - activerecord (= 7.1.4) - activestorage (= 7.1.4) - activesupport (= 7.1.4) + rails (7.2.2) + actioncable (= 7.2.2) + actionmailbox (= 7.2.2) + actionmailer (= 7.2.2) + actionpack (= 7.2.2) + actiontext (= 7.2.2) + actionview (= 7.2.2) + activejob (= 7.2.2) + activemodel (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) bundler (>= 1.15.0) - railties (= 7.1.4) + railties (= 7.2.2) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -660,16 +633,16 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.1) loofah (~> 2.21) - nokogiri (~> 1.14) - rails-i18n (7.0.9) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.4) - actionpack (= 7.1.4) - activesupport (= 7.1.4) - irb + railties (7.2.2) + actionpack (= 7.2.2) + activesupport (= 7.2.2) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) @@ -691,16 +664,16 @@ GEM redlock (1.3.2) redis (>= 3.0.0, < 6.0) regexp_parser (2.9.2) - reline (0.5.10) + reline (0.5.11) io-console (~> 0.5) request_store (1.6.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.7) + rexml (3.3.9) rotp (6.3.0) - rouge (4.3.0) + rouge (4.5.1) rpam2 (4.0.2) rqrcode (2.2.0) chunky_png (~> 1.0) @@ -710,17 +683,17 @@ GEM rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.1) + rspec-core (3.13.2) rspec-support (~> 3.13.0) - rspec-expectations (3.13.2) + rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.13.1) + rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.0.1) + rspec-rails (7.1.0) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -748,20 +721,20 @@ GEM parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) - rubocop-performance (1.21.1) + rubocop-performance (1.22.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.1) + rubocop-rails (2.27.0) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) + rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.0.4) + rubocop-rspec (3.2.0) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) rubocop-rspec (~> 3, >= 3.0.1) - ruby-prof (1.7.0) + ruby-prof (1.7.1) ruby-progressbar (1.13.0) ruby-saml (1.17.0) nokogiri (>= 1.13.10) @@ -769,7 +742,6 @@ GEM ruby-vips (2.2.2) ffi (~> 1.12) logger - ruby2_keywords (0.0.5) rubyzip (2.3.2) rufus-scheduler (3.9.1) fugit (~> 1.1, >= 1.1.6) @@ -781,7 +753,8 @@ GEM scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - selenium-webdriver (4.25.0) + securerandom (0.3.2) + selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -815,14 +788,14 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) stackprof (0.2.26) stoplight (4.1.0) redlock (~> 1.0) - stringio (3.1.1) - strong_migrations (2.0.0) + stringio (3.1.2) + strong_migrations (2.1.0) activerecord (>= 6.1) swd (1.3.0) activesupport (>= 3) @@ -837,7 +810,7 @@ GEM test-prof (1.4.2) thor (1.3.2) tilt (2.4.0) - timeout (0.4.1) + timeout (0.4.2) tpm-key_attestation (0.12.1) bindata (~> 2.4) openssl (> 2.0) @@ -862,8 +835,9 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) uri (0.13.1) + useragent (0.16.10) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -872,9 +846,8 @@ GEM public_suffix warden (1.2.9) rack (>= 2.0.9) - webauthn (3.1.0) + webauthn (3.2.2) android_key_attestation (~> 0.3.0) - awrence (~> 1.1) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) @@ -884,7 +857,7 @@ GEM webfinger (1.2.0) activesupport httpclient (>= 2.4) - webmock (3.23.1) + webmock (3.24.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -893,7 +866,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webrick (1.8.2) + webrick (1.9.0) websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -902,7 +875,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.18) + zeitwerk (2.7.1) PLATFORMS ruby @@ -910,14 +883,14 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) addressable (~> 2.8) - annotate (~> 3.2) + annotaterb (~> 4.13) aws-sdk-s3 (~> 1.123) better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) bootsnap (~> 1.18.0) brakeman (~> 6.0) - browser (< 6) + browser bundler-audit (~> 0.9) capybara (~> 3.39) charlock_holmes (~> 0.7.7) @@ -939,10 +912,11 @@ DEPENDENCIES email_spec fabrication (~> 2.30) faker (~> 3.2) + faraday-httpclient fast_blank (~> 1.0) fastimage flatware-rspec - fog-core (<= 2.5.0) + fog-core (<= 2.6.0) fog-openstack (~> 1.0) haml-rails (~> 2.0) haml_lint @@ -957,6 +931,7 @@ DEPENDENCIES idn-ruby inline_svg irb (~> 1.8) + jd-paperclip-azure (~> 3.0) json-ld json-ld-preloaded (~> 3.2) json-schema (~> 5.0) @@ -968,10 +943,10 @@ DEPENDENCIES lograge (~> 0.12) mail (~> 2.8) mario-redis-lock (~> 1.2) - md-paperclip-azure (~> 2.2) memory_profiler - mime-types (~> 3.5.0) - net-http (~> 0.4.0) + mime-types (~> 3.6.0) + mutex_m + net-http (~> 0.5.0) net-ldap (~> 0.18) nokogiri (~> 1.15) oj (~> 3.14) @@ -991,8 +966,8 @@ DEPENDENCIES opentelemetry-instrumentation-http_client (~> 0.22.3) opentelemetry-instrumentation-net_http (~> 0.22.4) opentelemetry-instrumentation-pg (~> 0.29.0) - opentelemetry-instrumentation-rack (~> 0.24.1) - opentelemetry-instrumentation-rails (~> 0.31.0) + opentelemetry-instrumentation-rack (~> 0.25.0) + opentelemetry-instrumentation-rails (~> 0.33.0) opentelemetry-instrumentation-redis (~> 0.25.3) opentelemetry-instrumentation-sidekiq (~> 0.25.2) opentelemetry-sdk (~> 1.4) @@ -1009,7 +984,7 @@ DEPENDENCIES rack-attack (~> 6.6) rack-cors (~> 2.0) rack-test (~> 2.1) - rails (~> 7.1.1) + rails (~> 7.2.0) rails-controller-testing (~> 1.0) rails-i18n (~> 7.0) rdf-normalize (~> 0.5) @@ -1057,7 +1032,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.3.4p94 + ruby 3.3.6p108 BUNDLED WITH - 2.5.18 + 2.5.23 diff --git a/README.md b/README.md index 9c0b0d20ed..17d9eefb57 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Stre - **PostgreSQL** 12+ - **Redis** 4+ -- **Ruby** 3.1+ +- **Ruby** 3.2+ - **Node.js** 18+ The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. diff --git a/Rakefile b/Rakefile index e51cf0e17e..488c551fee 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,6 @@ # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. -require File.expand_path('config/application', __dir__) +require_relative 'config/application' Rails.application.load_tasks diff --git a/SECURITY.md b/SECURITY.md index 156954ce02..43ab4454c4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through ## Supported Versions -| Version | Supported | -| ------- | --------- | -| 4.2.x | Yes | -| 4.1.x | Yes | -| < 4.1 | No | +| Version | Supported | +| ------- | ---------------- | +| 4.3.x | Yes | +| 4.2.x | Yes | +| 4.1.x | Until 2025-04-08 | +| < 4.1 | No | diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index b8baf64e1a..0c995edbf8 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -41,11 +41,11 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController end end - def outbox_url(**kwargs) + def outbox_url(**) if params[:account_username].present? - account_outbox_url(@account, **kwargs) + account_outbox_url(@account, **) else - instance_actor_outbox_url(**kwargs) + instance_actor_outbox_url(**) end end diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb index 8f9708183a..12230a6506 100644 --- a/app/controllers/admin/announcements_controller.rb +++ b/app/controllers/admin/announcements_controller.rb @@ -6,6 +6,7 @@ class Admin::AnnouncementsController < Admin::BaseController def index authorize :announcement, :index? + @published_announcements_count = Announcement.published.async_count end def new diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb index 5e342409b0..0c41553676 100644 --- a/app/controllers/admin/disputes/appeals_controller.rb +++ b/app/controllers/admin/disputes/appeals_controller.rb @@ -6,6 +6,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController def index authorize :appeal, :index? + @pending_appeals_count = Appeal.pending.async_count @appeals = filtered_appeals.page(params[:page]) end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index faa0a061a6..9501ebd63a 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -5,7 +5,7 @@ module Admin def index authorize :email_domain_block, :index? - @email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page]) + @email_domain_blocks = EmailDomainBlock.parents.includes(:children).order(id: :desc).page(params[:page]) @form = Form::EmailDomainBlockBatch.new end @@ -58,10 +58,7 @@ module Admin private def set_resolved_records - Resolv::DNS.open do |dns| - dns.timeouts = 5 - @resolved_records = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a - end + @resolved_records = DomainResource.new(@email_domain_block.domain).mx end def resource_params diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index d7f88a71f3..a48c4773ed 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -5,6 +5,8 @@ module Admin before_action :set_instances, only: :index before_action :set_instance, except: :index + LOGS_LIMIT = 5 + def index authorize :instance, :index? preload_delivery_failures! @@ -13,7 +15,7 @@ module Admin def show authorize :instance, :show? @time_period = (6.days.ago.to_date...Time.now.utc.to_date) - @action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5) + @action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT) end def destroy diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb index dabfe97655..614e2a32d0 100644 --- a/app/controllers/admin/invites_controller.rb +++ b/app/controllers/admin/invites_controller.rb @@ -32,7 +32,7 @@ module Admin def deactivate_all authorize :invite, :deactivate_all? - Invite.available.in_batches.update_all(expires_at: Time.now.utc) + Invite.available.in_batches.touch_all(:expires_at) redirect_to admin_invites_path end diff --git a/app/controllers/admin/relays_controller.rb b/app/controllers/admin/relays_controller.rb index c893802159..f05255adb6 100644 --- a/app/controllers/admin/relays_controller.rb +++ b/app/controllers/admin/relays_controller.rb @@ -21,6 +21,7 @@ module Admin @relay = Relay.new(resource_params) if @relay.save + log_action :create, @relay @relay.enable! redirect_to admin_relays_path else @@ -31,18 +32,21 @@ module Admin def destroy authorize :relay, :update? @relay.destroy + log_action :destroy, @relay redirect_to admin_relays_path end def enable authorize :relay, :update? @relay.enable! + log_action :enable, @relay redirect_to admin_relays_path end def disable authorize :relay, :update? @relay.disable! + log_action :disable, @relay redirect_to admin_relays_path end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index e53b22dca3..40d1a481b2 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -16,6 +16,8 @@ module Admin def show authorize [:admin, @status], :show? + + @status_batch_action = Admin::StatusBatchAction.new end def batch diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb index 768b79f8db..5e4b4084f8 100644 --- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll def index authorize :preview_card_provider, :review? + @pending_preview_card_providers_count = PreviewCardProvider.unreviewed.async_count @preview_card_providers = filtered_preview_card_providers.page(params[:page]) @form = Trends::PreviewCardProviderBatch.new end diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb index 83d68eba63..65eca11c7f 100644 --- a/app/controllers/admin/trends/links_controller.rb +++ b/app/controllers/admin/trends/links_controller.rb @@ -4,7 +4,7 @@ class Admin::Trends::LinksController < Admin::BaseController def index authorize :preview_card, :review? - @locales = PreviewCardTrend.pluck('distinct language') + @locales = PreviewCardTrend.locales @preview_cards = filtered_preview_cards.page(params[:page]) @form = Trends::PreviewCardBatch.new end diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb index 3d8b53ea8a..682fe70bb5 100644 --- a/app/controllers/admin/trends/statuses_controller.rb +++ b/app/controllers/admin/trends/statuses_controller.rb @@ -4,7 +4,7 @@ class Admin::Trends::StatusesController < Admin::BaseController def index authorize [:admin, :status], :review? - @locales = StatusTrend.pluck('distinct language') + @locales = StatusTrend.locales @statuses = filtered_statuses.page(params[:page]) @form = Trends::StatusBatch.new end diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb index f5946448ae..fcd23fbf66 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController def index authorize :tag, :review? + @pending_tags_count = Tag.pending_review.async_count @tags = filtered_tags.page(params[:page]) @form = Trends::TagBatch.new end diff --git a/app/controllers/api/v1/accounts/familiar_followers_controller.rb b/app/controllers/api/v1/accounts/familiar_followers_controller.rb index a49eb2eb27..81f0a9ed0f 100644 --- a/app/controllers/api/v1/accounts/familiar_followers_controller.rb +++ b/app/controllers/api/v1/accounts/familiar_followers_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Accounts::FamiliarFollowersController < Api::BaseController private def set_accounts - @accounts = Account.without_suspended.where(id: account_ids).select('id, hide_collections') + @accounts = Account.without_suspended.where(id: account_ids).select(:id, :hide_collections) end def familiar_followers diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 84b604b305..f7d3de7f94 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -16,6 +16,7 @@ class Api::V1::AccountsController < Api::BaseController before_action :check_account_confirmation, except: [:index, :create] before_action :check_enabled_registrations, only: [:create] before_action :check_accounts_limit, only: [:index] + before_action :check_following_self, only: [:follow] skip_before_action :require_authenticated_user!, only: :create @@ -101,8 +102,12 @@ class Api::V1::AccountsController < Api::BaseController raise(Mastodon::ValidationError) if account_ids.size > DEFAULT_ACCOUNTS_LIMIT end - def relationships(**options) - AccountRelationshipsPresenter.new([@account], current_user.account_id, **options) + def check_following_self + render json: { error: I18n.t('accounts.self_follow_error') }, status: 403 if current_user.account.id == @account.id + end + + def relationships(**) + AccountRelationshipsPresenter.new([@account], current_user.account_id, **) end def account_ids diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb index 9bc8e68ac2..b1aee288dd 100644 --- a/app/controllers/api/v1/annual_reports_controller.rb +++ b/app/controllers/api/v1/annual_reports_controller.rb @@ -17,6 +17,17 @@ class Api::V1::AnnualReportsController < Api::BaseController relationships: @relationships end + def show + with_read_replica do + @presenter = AnnualReportsPresenter.new([@annual_report]) + @relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id) + end + + render json: @presenter, + serializer: REST::AnnualReportsSerializer, + relationships: @relationships + end + def read @annual_report.view! render_empty diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 9c72e4380d..d533b1af7b 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -5,6 +5,8 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController before_action :require_user! before_action :set_recently_used_tags, only: :index + RECENT_TAGS_LIMIT = 10 + def index render json: @recently_used_tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@recently_used_tags, current_user&.account_id) end @@ -12,6 +14,6 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController private def set_recently_used_tags - @recently_used_tags = Tag.suggestions_for_account(current_account).limit(10) + @recently_used_tags = Tag.suggestions_for_account(current_account).limit(RECENT_TAGS_LIMIT) end end diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index 29a09fceef..4b44cfe531 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -28,8 +28,8 @@ class Api::V1::FollowRequestsController < Api::BaseController @account ||= Account.find(params[:id]) end - def relationships(**options) - AccountRelationshipsPresenter.new([account], current_user.account_id, **options) + def relationships(**) + AccountRelationshipsPresenter.new([account], current_user.account_id, **) end def load_accounts diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index b1c0e609d0..616159f05f 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -15,17 +15,12 @@ class Api::V1::Lists::AccountsController < Api::BaseController end def create - ApplicationRecord.transaction do - list_accounts.each do |account| - @list.accounts << account - end - end - + AddAccountsToListService.new.call(@list, Account.find(account_ids)) render_empty end def destroy - ListAccount.where(list: @list, account_id: account_ids).destroy_all + RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids)) render_empty end @@ -43,10 +38,6 @@ class Api::V1::Lists::AccountsController < Api::BaseController end end - def list_accounts - Account.find(account_ids) - end - def account_ids Array(resource_params[:account_ids]) end diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb index 36ee073b9c..3c90f13ce2 100644 --- a/app/controllers/api/v1/notifications/requests_controller.rb +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -52,7 +52,7 @@ class Api::V1::Notifications::RequestsController < Api::BaseController private def load_requests - requests = NotificationRequest.where(account: current_account).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( + requests = NotificationRequest.where(account: current_account).without_suspended.includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), params_slice(:max_id, :since_id, :min_id) ) diff --git a/app/controllers/api/v1/statuses/translations_controller.rb b/app/controllers/api/v1/statuses/translations_controller.rb index 8cf495f78a..bd5cd9bb07 100644 --- a/app/controllers/api/v1/statuses/translations_controller.rb +++ b/app/controllers/api/v1/statuses/translations_controller.rb @@ -23,6 +23,6 @@ class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseControl private def set_translation - @translation = TranslateStatusService.new.call(@status, content_locale) + @translation = TranslateStatusService.new.call(@status, I18n.locale.to_s) end end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index b15dd50131..10a3442344 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -27,7 +27,9 @@ class Api::V1::Trends::TagsController < Api::BaseController end def tags_from_trends - Trends.tags.query.allowed + scope = Trends.tags.query.allowed.in_locale(content_locale) + scope = scope.filtered_for(current_account) if user_signed_in? + scope end def next_path diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 167d16fc4d..f515961427 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::Web::PushSubscriptionsController < Api::Web::BaseController - before_action :require_user! + before_action :require_user!, except: :destroy before_action :set_push_subscription, only: :update before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions? after_action :update_session_with_subscription, only: :create @@ -17,6 +17,13 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end + def destroy + push_subscription = ::Web::PushSubscription.find_by_token_for(:unsubscribe, params[:id]) + push_subscription&.destroy + + head 200 + end + private def active_session diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 62e3355ae6..7a858ed059 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,7 +22,6 @@ class ApplicationController < ActionController::Base helper_method :use_seamless_external_login? helper_method :sso_account_settings helper_method :limited_federation_mode? - helper_method :body_class_string helper_method :skip_csrf_meta_tags? rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request @@ -32,7 +31,7 @@ class ApplicationController < ActionController::Base rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests - rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable rescue_from Seahorse::Client::NetworkingError do |e| @@ -158,10 +157,6 @@ class ApplicationController < ActionController::Base current_user.setting_theme end - def body_class_string - @body_classes || '' - end - def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } diff --git a/app/controllers/concerns/api/error_handling.rb b/app/controllers/concerns/api/error_handling.rb index ad559fe2d7..9ce4795b02 100644 --- a/app/controllers/concerns/api/error_handling.rb +++ b/app/controllers/concerns/api/error_handling.rb @@ -20,7 +20,7 @@ module Api::ErrorHandling render json: { error: 'Record not found' }, status: 404 end - rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, Mastodon::UnexpectedResponseError) do render json: { error: 'Remote data could not be fetched' }, status: 503 end diff --git a/app/controllers/concerns/auth/captcha_concern.rb b/app/controllers/concerns/auth/captcha_concern.rb index cfd93978ce..c01da21249 100644 --- a/app/controllers/concerns/auth/captcha_concern.rb +++ b/app/controllers/concerns/auth/captcha_concern.rb @@ -10,7 +10,7 @@ module Auth::CaptchaConcern end def captcha_available? - ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present? end def captcha_enabled? diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 1823b5b8ed..b1b09f2aab 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -28,7 +28,7 @@ module CacheConcern def render_with_cache(**options) raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given? - key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') + key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':') expires_in = options.delete(:expires_in) || 3.minutes body = Rails.cache.read(key, raw: true) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 68f09ee023..4ae63632c0 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -80,7 +80,7 @@ module SignatureVerification fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature'] rescue SignatureVerificationError => e fail_with! e.message - rescue HTTP::Error, OpenSSL::SSL::SSLError => e + rescue *Mastodon::HTTP_CONNECTION_ERRORS => e fail_with! "Failed to fetch remote data: #{e.message}" rescue Mastodon::UnexpectedResponseError fail_with! 'Failed to fetch remote data (got unexpected reply from server)' diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index ebbdba59af..249bb20a25 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -7,13 +7,12 @@ module WebAppControllerConcern vary_by 'Accept, Accept-Language, Cookie' before_action :redirect_unauthenticated_to_permalinks! - before_action :set_app_body_class content_security_policy do |p| policy = ContentSecurityPolicy.new if policy.sso_host.present? - p.form_action policy.sso_host + p.form_action policy.sso_host, -> { "https://#{request.host}/auth/auth/" } else p.form_action :none end @@ -24,10 +23,6 @@ module WebAppControllerConcern !(ENV['ONE_CLICK_SSO_LOGIN'] == 'true' && ENV['OMNIAUTH_ONLY'] == 'true' && Devise.omniauth_providers.length == 1) && current_user.nil? end - def set_app_body_class - @body_classes = 'app-body' - end - def redirect_unauthenticated_to_permalinks! return if user_signed_in? && current_account.moved_to_account_id.nil? diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index c4230d62c3..f68d85e44e 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -13,7 +13,7 @@ class MediaProxyController < ApplicationController rescue_from ActiveRecord::RecordInvalid, with: :not_found rescue_from Mastodon::UnexpectedResponseError, with: :not_found rescue_from Mastodon::NotPermittedError, with: :not_found - rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) def show with_redis_lock("media_download:#{params[:id]}") do diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 267409a9ce..9e541e5e3c 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -35,12 +35,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio end def set_last_used_at_by_app - @last_used_at_by_app = Doorkeeper::AccessToken - .select('DISTINCT ON (application_id) application_id, last_used_at') - .where(resource_owner_id: current_resource_owner.id) - .where.not(last_used_at: nil) - .order(application_id: :desc, last_used_at: :desc) - .pluck(:application_id, :last_used_at) - .to_h + @last_used_at_by_app = current_resource_owner.applications_last_used end end diff --git a/app/controllers/oauth/userinfo_controller.rb b/app/controllers/oauth/userinfo_controller.rb new file mode 100644 index 0000000000..e268b70dcc --- /dev/null +++ b/app/controllers/oauth/userinfo_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Oauth::UserinfoController < Api::BaseController + before_action -> { doorkeeper_authorize! :profile }, only: [:show] + before_action :require_user! + + def show + @account = current_account + render json: @account, serializer: OauthUserinfoSerializer + end +end diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index 076ed5dadb..263d20eaea 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -9,7 +9,7 @@ class Settings::ExportsController < Settings::BaseController skip_before_action :require_functional! def show - @export = Export.new(current_account) + @export_summary = ExportSummary.new(preloaded_account) @backups = current_user.backups end @@ -25,4 +25,15 @@ class Settings::ExportsController < Settings::BaseController redirect_to settings_export_path end + + private + + def preloaded_account + current_account.tap do |account| + ActiveRecord::Associations::Preloader.new( + records: [account], + associations: :account_stat + ).call + end + end end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index 90c112e219..7e29dd1d29 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -5,6 +5,8 @@ class Settings::FeaturedTagsController < Settings::BaseController before_action :set_featured_tag, except: [:index, :create] before_action :set_recently_used_tags, only: :index + RECENT_TAGS_LIMIT = 10 + def index @featured_tag = FeaturedTag.new end @@ -38,7 +40,7 @@ class Settings::FeaturedTagsController < Settings::BaseController end def set_recently_used_tags - @recently_used_tags = Tag.suggestions_for_account(current_account).limit(10) + @recently_used_tags = Tag.suggestions_for_account(current_account).limit(RECENT_TAGS_LIMIT) end def featured_tag_params diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index 569aa07c53..5346a448a3 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -24,6 +24,8 @@ class Settings::ImportsController < Settings::BaseController lists: false, }.freeze + RECENT_IMPORTS_LIMIT = 10 + def index @import = Form::Import.new(current_account: current_account) end @@ -96,6 +98,6 @@ class Settings::ImportsController < Settings::BaseController end def set_recent_imports - @recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(10) + @recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(RECENT_IMPORTS_LIMIT) end end diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb index 0bff01ec27..ca8d46afe4 100644 --- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -15,7 +15,7 @@ module Settings end def create - session[:new_otp_secret] = User.generate_otp_secret(32) + session[:new_otp_secret] = User.generate_otp_secret redirect_to new_settings_two_factor_authentication_confirmation_path end diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 201da9fbc3..6dee587baf 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -7,7 +7,23 @@ module WellKnown def show @webfinger_template = "#{webfinger_url}?resource={uri}" expires_in 3.days, public: true - render content_type: 'application/xrd+xml', formats: [:xml] + + respond_to do |format| + format.any do + render content_type: 'application/xrd+xml', formats: [:xml] + end + + format.json do + render json: { + links: [ + { + rel: 'lrdd', + template: @webfinger_template, + }, + ], + } + end + end end end end diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb index 2a3d954a35..7c931c1157 100644 --- a/app/helpers/admin/account_moderation_notes_helper.rb +++ b/app/helpers/admin/account_moderation_notes_helper.rb @@ -12,12 +12,12 @@ module Admin::AccountModerationNotesHelper ) end - def admin_account_inline_link_to(account) + def admin_account_inline_link_to(account, path: nil) return if account.nil? link_to( account_inline_text(account), - admin_account_path(account.id), + path || admin_account_path(account.id), class: class_names('inline-name-tag', suspended: suspended_account?(account)), title: account.acct ) diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index e8d5634126..859f924687 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -33,6 +33,15 @@ module Admin::ActionLogsHelper else I18n.t('admin.action_logs.deleted_account') end + when 'Relay' + link_to log.human_identifier, admin_relays_path end end + + def sorted_action_log_types + Admin::ActionLogFilter::ACTION_TYPE_MAP + .keys + .map { |key| [I18n.t("admin.action_logs.action_types.#{key}"), key] } + .sort_by(&:first) + end end diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb index 6096ff1381..f87fdad708 100644 --- a/app/helpers/admin/dashboard_helper.rb +++ b/app/helpers/admin/dashboard_helper.rb @@ -18,6 +18,11 @@ module Admin::DashboardHelper end end + def date_range(range) + [l(range.first), l(range.last)] + .join(' - ') + end + def relevant_account_timestamp(account) timestamp, exact = if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago [account.user_current_sign_in_at, true] @@ -25,6 +30,8 @@ module Admin::DashboardHelper [account.user_current_sign_in_at, false] elsif account.user_pending? [account.user_created_at, true] + elsif account.suspended_at.present? && account.local? && account.user.nil? + [account.suspended_at, true] elsif account.last_status_at.present? [account.last_status_at, true] else diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb index 6937331e1a..9b950d5a63 100644 --- a/app/helpers/admin/settings_helper.rb +++ b/app/helpers/admin/settings_helper.rb @@ -2,7 +2,7 @@ module Admin::SettingsHelper def captcha_available? - ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present? end def login_activity_title(activity) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de00f76d36..e1ca536c7d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true module ApplicationHelper - DANGEROUS_SCOPES = %w( - read - write - follow - ).freeze - RTL_LOCALES = %i( ar ckb @@ -85,7 +79,7 @@ module ApplicationHelper def html_title safe_join( - [content_for(:page_title).to_s.chomp, title] + [content_for(:page_title), title] .compact_blank, ' - ' ) @@ -95,8 +89,11 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end - def class_for_scope(scope) - 'scope-danger' if DANGEROUS_SCOPES.include?(scope.to_s) + def label_for_scope(scope) + safe_join [ + tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), + tag.span(t("doorkeeper.scopes.#{scope}"), class: :hint), + ] end def can?(action, record) @@ -123,18 +120,6 @@ module ApplicationHelper inline_svg_tag 'check.svg' end - def visibility_icon(status) - if status.public_visibility? - material_symbol('globe', title: I18n.t('statuses.visibilities.public')) - elsif status.unlisted_visibility? - material_symbol('lock_open', title: I18n.t('statuses.visibilities.unlisted')) - elsif status.private_visibility? || status.limited_visibility? - material_symbol('lock', title: I18n.t('statuses.visibilities.private')) - elsif status.direct_visibility? - material_symbol('alternate_email', title: I18n.t('statuses.visibilities.direct')) - end - end - def interrelationships_icon(relationships, account_id) if relationships.following[account_id] && relationships.followed_by[account_id] material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive') @@ -158,10 +143,11 @@ module ApplicationHelper end def body_classes - output = body_class_string.split + output = [] output << content_for(:body_classes) output << "theme-#{current_theme.parameterize}" output << 'system-font' if current_account&.user&.setting_system_font_ui + output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') output << 'rtl' if locale_direction == 'rtl' output.compact_blank.join(' ') @@ -243,6 +229,15 @@ module ApplicationHelper full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg')) end + def copyable_input(options = {}) + tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options) + end + + def recent_tag_usage(tag) + people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts + I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people + end + private def storage_host_var diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 7d1423e52d..9d5a2e2478 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true module FormattingHelper + SYNDICATED_EMOJI_STYLES = <<~CSS.squish + height: 1.1em; + margin: -.2ex .15em .2ex; + object-fit: contain; + vertical-align: middle; + width: 1.1em; + CSS + def html_aware_format(text, local, options = {}) HtmlAwareFormatter.new(text, local, options).to_s end @@ -19,42 +27,33 @@ module FormattingHelper module_function :extract_status_plain_text def status_content_format(status) - html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span| + span.add_attributes( + 'app.formatter.content.type' => 'status', + 'app.formatter.content.origin' => status.local? ? 'local' : 'remote' + ) + + html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + end end def rss_status_content_format(status) - html = status_content_format(status) - - before_html = if status.spoiler_text? - tag.p do - tag.strong do - I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale) - end - - status.spoiler_text - end + tag.hr - end - - after_html = if status.preloadable_poll - tag.p do - safe_join( - status.preloadable_poll.options.map do |o| - tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', o, disabled: true) - end, - tag.br - ) - end - end - prerender_custom_emojis( - safe_join([before_html, html, after_html]), + wrapped_status_content_format(status), status.emojis, - style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' + style: SYNDICATED_EMOJI_STYLES ).to_str end def account_bio_format(account) - html_aware_format(account.note, account.local?) + MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span| + span.add_attributes( + 'app.formatter.content.type' => 'account_bio', + 'app.formatter.content.origin' => account.local? ? 'local' : 'remote' + ) + + html_aware_format(account.note, account.local?) + end end def account_field_value_format(field, with_rel_me: true) @@ -64,4 +63,47 @@ module FormattingHelper html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false) end end + + private + + def wrapped_status_content_format(status) + safe_join [ + rss_content_preroll(status), + status_content_format(status), + rss_content_postroll(status), + ] + end + + def rss_content_preroll(status) + if status.spoiler_text? + safe_join [ + tag.p { spoiler_with_warning(status) }, + tag.hr, + ] + end + end + + def spoiler_with_warning(status) + safe_join [ + tag.strong { I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale) }, + status.spoiler_text, + ] + end + + def rss_content_postroll(status) + if status.preloadable_poll + tag.p do + poll_option_tags(status) + end + end + end + + def poll_option_tags(status) + safe_join( + status.preloadable_poll.options.map do |option| + tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', option, disabled: true) + end, + tag.br + ) + end end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index b6c09b7314..0a8ebcde54 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -162,7 +162,7 @@ module LanguagesHelper th: ['Thai', 'ไทย'].freeze, ti: ['Tigrinya', 'ትግርኛ'].freeze, tk: ['Turkmen', 'Türkmen'].freeze, - tl: ['Tagalog', 'Wikang Tagalog'].freeze, + tl: ['Tagalog', 'Tagalog'].freeze, tn: ['Tswana', 'Setswana'].freeze, to: ['Tonga', 'faka Tonga'].freeze, tr: ['Turkish', 'Türkçe'].freeze, @@ -193,6 +193,7 @@ module LanguagesHelper ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze, csb: ['Kashubian', 'Kaszëbsczi'].freeze, + gsw: ['Swiss German', 'Schwiizertütsch'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze, ldn: ['Láadan', 'Láadan'].freeze, diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb index 60ccdd0835..269566528a 100644 --- a/app/helpers/media_component_helper.rb +++ b/app/helpers/media_component_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module MediaComponentHelper - def render_video_component(status, **options) + def render_video_component(status, **) video = status.ordered_media_attachments.first meta = video.file.meta || {} @@ -18,14 +18,14 @@ module MediaComponentHelper media: [ serialize_media_attachment(video), ].as_json, - }.merge(**options) + }.merge(**) react_component :video, component_params do render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } end end - def render_audio_component(status, **options) + def render_audio_component(status, **) audio = status.ordered_media_attachments.first meta = audio.file.meta || {} @@ -38,19 +38,19 @@ module MediaComponentHelper foregroundColor: meta.dig('colors', 'foreground'), accentColor: meta.dig('colors', 'accent'), duration: meta.dig('original', 'duration'), - }.merge(**options) + }.merge(**) react_component :audio, component_params do render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } end end - def render_media_gallery_component(status, **options) + def render_media_gallery_component(status, **) component_params = { sensitive: sensitive_viewer?(status, current_account), autoplay: prefers_autoplay?, media: status.ordered_media_attachments.map { |a| serialize_media_attachment(a).as_json }, - }.merge(**options) + }.merge(**) react_component :media_gallery, component_params do render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index ef5462ac88..002d167c05 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -16,6 +16,6 @@ module RegistrationHelper end def ip_blocked?(remote_ip) - IpBlock.where(severity: :sign_up_block).exists?(['ip >>= ?', remote_ip.to_s]) + IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? end end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 15d988f64d..22efc5f092 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -14,8 +14,8 @@ module RoutingHelper end end - def full_asset_url(source, **options) - source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage? + def full_asset_url(source, **) + source = ActionController::Base.helpers.asset_url(source, **) unless use_storage? URI.join(asset_host, source).to_s end @@ -24,12 +24,12 @@ module RoutingHelper Rails.configuration.action_controller.asset_host || root_url end - def frontend_asset_path(source, **options) - asset_pack_path("media/#{source}", **options) + def frontend_asset_path(source, **) + asset_pack_path("media/#{source}", **) end - def frontend_asset_url(source, **options) - full_asset_url(frontend_asset_path(source, **options)) + def frontend_asset_url(source, **) + full_asset_url(frontend_asset_path(source, **)) end def use_storage? diff --git a/app/helpers/self_destruct_helper.rb b/app/helpers/self_destruct_helper.rb index 78557c25e5..f1927b1e04 100644 --- a/app/helpers/self_destruct_helper.rb +++ b/app/helpers/self_destruct_helper.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true module SelfDestructHelper + VERIFY_PURPOSE = 'self-destruct' + def self.self_destruct? - value = ENV.fetch('SELF_DESTRUCT', nil) - value.present? && Rails.application.message_verifier('self-destruct').verify(value) == ENV['LOCAL_DOMAIN'] + value = Rails.configuration.x.mastodon.self_destruct_value + value.present? && Rails.application.message_verifier(VERIFY_PURPOSE).verify(value) == ENV['LOCAL_DOMAIN'] rescue ActiveSupport::MessageVerifier::InvalidSignature false end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 64f2ad70a6..fd631ce92e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -10,16 +10,17 @@ module SettingsHelper end def featured_tags_hint(recently_used_tags) - safe_join( - [ - t('simple_form.hints.featured_tag.name'), - safe_join( - links_for_featured_tags(recently_used_tags), - ', ' - ), - ], - ' ' - ) + recently_used_tags.present? && + safe_join( + [ + t('simple_form.hints.featured_tag.name'), + safe_join( + links_for_featured_tags(recently_used_tags), + ', ' + ), + ], + ' ' + ) end def session_device_icon(session) diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index bba6d64a47..16b9d3fb53 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true module StatusesHelper - EMBEDDED_CONTROLLER = 'statuses' - EMBEDDED_ACTION = 'embed' - VISIBLITY_ICONS = { public: 'globe', unlisted: 'lock_open', @@ -12,7 +9,7 @@ module StatusesHelper }.freeze def nothing_here(extra_classes = '') - content_tag(:div, class: "nothing-here #{extra_classes}") do + tag.div(class: ['nothing-here', extra_classes]) do t('accounts.nothing_here') end end @@ -60,18 +57,10 @@ module StatusesHelper components.compact_blank.join("\n\n") end - def stream_link_target - embedded_view? ? '_blank' : nil - end - def visibility_icon(status) VISIBLITY_ICONS[status.visibility.to_sym] end - def embedded_view? - params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION - end - def prefers_autoplay? ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif end diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb deleted file mode 100644 index 482f4e19ea..0000000000 --- a/app/helpers/webfinger_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module WebfingerHelper - def webfinger!(uri) - Webfinger.new(uri).perform - end -end diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index d33e00d5da..9e8ff9caa1 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -230,62 +230,6 @@ function loaded() { } }, ); - - Rails.delegate( - document, - 'button.status__content__spoiler-link', - 'click', - function () { - if (!(this instanceof HTMLButtonElement)) return; - - const statusEl = this.parentNode?.parentNode; - - if ( - !( - statusEl instanceof HTMLDivElement && - statusEl.classList.contains('.status__content') - ) - ) - return; - - if (statusEl.dataset.spoiler === 'expanded') { - statusEl.dataset.spoiler = 'folded'; - this.textContent = new IntlMessageFormat( - localeData['status.show_more'] ?? 'Show more', - locale, - ).format() as string; - } else { - statusEl.dataset.spoiler = 'expanded'; - this.textContent = new IntlMessageFormat( - localeData['status.show_less'] ?? 'Show less', - locale, - ).format() as string; - } - }, - ); - - document - .querySelectorAll('button.status__content__spoiler-link') - .forEach((spoilerLink) => { - const statusEl = spoilerLink.parentNode?.parentNode; - - if ( - !( - statusEl instanceof HTMLDivElement && - statusEl.classList.contains('.status__content') - ) - ) - return; - - 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() as string; - }); } Rails.delegate( @@ -327,31 +271,24 @@ Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { if (!input) return; - const oldReadOnly = input.readOnly; - - input.readOnly = false; - input.focus(); - input.select(); - input.setSelectionRange(0, input.value.length); - - try { - if (document.execCommand('copy')) { - input.blur(); - + navigator.clipboard + .writeText(input.value) + .then(() => { const parent = target.parentElement; - if (!parent) return; - parent.classList.add('copied'); + if (parent) { + parent.classList.add('copied'); - setTimeout(() => { - parent.classList.remove('copied'); - }, 700); - } - } catch (err) { - console.error(err); - } + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } - input.readOnly = oldReadOnly; + return true; + }) + .catch((error: unknown) => { + console.error(error); + }); }); const toggleSidebar = () => { @@ -446,6 +383,24 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { }); }); +Rails.delegate(document, '.rules-list button', 'click', ({ target }) => { + if (!(target instanceof HTMLElement)) { + return; + } + + const button = target.closest('button'); + + if (!button) { + return; + } + + if (button.ariaExpanded === 'true') { + button.ariaExpanded = 'false'; + } else { + button.ariaExpanded = 'true'; + } +}); + function main() { ready(loaded).catch((error: unknown) => { console.error(error); diff --git a/app/javascript/images/archetypes/booster.png b/app/javascript/images/archetypes/booster.png new file mode 100755 index 0000000000..18c92dfb7d Binary files /dev/null and b/app/javascript/images/archetypes/booster.png differ diff --git a/app/javascript/images/archetypes/lurker.png b/app/javascript/images/archetypes/lurker.png new file mode 100755 index 0000000000..8e1d6451b0 Binary files /dev/null and b/app/javascript/images/archetypes/lurker.png differ diff --git a/app/javascript/images/archetypes/oracle.png b/app/javascript/images/archetypes/oracle.png new file mode 100755 index 0000000000..2afd3c72e1 Binary files /dev/null and b/app/javascript/images/archetypes/oracle.png differ diff --git a/app/javascript/images/archetypes/pollster.png b/app/javascript/images/archetypes/pollster.png new file mode 100755 index 0000000000..b838fccdd6 Binary files /dev/null and b/app/javascript/images/archetypes/pollster.png differ diff --git a/app/javascript/images/archetypes/replier.png b/app/javascript/images/archetypes/replier.png new file mode 100755 index 0000000000..b298d4221c Binary files /dev/null and b/app/javascript/images/archetypes/replier.png differ diff --git a/app/javascript/images/logo_full.svg b/app/javascript/images/logo_full.svg deleted file mode 100644 index 03bcf93e39..0000000000 --- a/app/javascript/images/logo_full.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/javascript/images/logo_transparent.svg b/app/javascript/images/logo_transparent.svg deleted file mode 100644 index a1e7b403e0..0000000000 --- a/app/javascript/images/logo_transparent.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/javascript/images/quote.svg b/app/javascript/images/quote.svg new file mode 100644 index 0000000000..ae6fbbe04a --- /dev/null +++ b/app/javascript/images/quote.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js deleted file mode 100644 index 48dee2587f..0000000000 --- a/app/javascript/mastodon/actions/alerts.js +++ /dev/null @@ -1,66 +0,0 @@ -import { defineMessages } from 'react-intl'; - -import { AxiosError } from 'axios'; - -const messages = defineMessages({ - unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, - unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, - rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, - rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, -}); - -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; -export const ALERT_NOOP = 'ALERT_NOOP'; - -export const dismissAlert = alert => ({ - type: ALERT_DISMISS, - alert, -}); - -export const clearAlert = () => ({ - type: ALERT_CLEAR, -}); - -export const showAlert = alert => ({ - type: ALERT_SHOW, - alert, -}); - -export const showAlertForError = (error, skipNotFound = false) => { - if (error.response) { - const { data, status, statusText, headers } = error.response; - - // Skip these errors as they are reflected in the UI - if (skipNotFound && (status === 404 || status === 410)) { - return { type: ALERT_NOOP }; - } - - // Rate limit errors - if (status === 429 && headers['x-ratelimit-reset']) { - return showAlert({ - title: messages.rateLimitedTitle, - message: messages.rateLimitedMessage, - values: { 'retry_time': new Date(headers['x-ratelimit-reset']) }, - }); - } - - return showAlert({ - title: `${status}`, - message: data.error || statusText, - }); - } - - // An aborted request, e.g. due to reloading the browser window, it not really error - if (error.code === AxiosError.ECONNABORTED) { - return { type: ALERT_NOOP }; - } - - console.error(error); - - return showAlert({ - title: messages.unexpectedTitle, - message: messages.unexpectedMessage, - }); -}; diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts new file mode 100644 index 0000000000..a521f3ef35 --- /dev/null +++ b/app/javascript/mastodon/actions/alerts.ts @@ -0,0 +1,90 @@ +import { defineMessages } from 'react-intl'; +import type { MessageDescriptor } from 'react-intl'; + +import { AxiosError } from 'axios'; +import type { AxiosResponse } from 'axios'; + +interface Alert { + title: string | MessageDescriptor; + message: string | MessageDescriptor; + values?: Record; +} + +interface ApiErrorResponse { + error?: string; +} + +const messages = defineMessages({ + unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, + unexpectedMessage: { + id: 'alert.unexpected.message', + defaultMessage: 'An unexpected error occurred.', + }, + rateLimitedTitle: { + id: 'alert.rate_limited.title', + defaultMessage: 'Rate limited', + }, + rateLimitedMessage: { + id: 'alert.rate_limited.message', + defaultMessage: 'Please retry after {retry_time, time, medium}.', + }, +}); + +export const ALERT_SHOW = 'ALERT_SHOW'; +export const ALERT_DISMISS = 'ALERT_DISMISS'; +export const ALERT_CLEAR = 'ALERT_CLEAR'; +export const ALERT_NOOP = 'ALERT_NOOP'; + +export const dismissAlert = (alert: Alert) => ({ + type: ALERT_DISMISS, + alert, +}); + +export const clearAlert = () => ({ + type: ALERT_CLEAR, +}); + +export const showAlert = (alert: Alert) => ({ + type: ALERT_SHOW, + alert, +}); + +export const showAlertForError = (error: unknown, skipNotFound = false) => { + if (error instanceof AxiosError && error.response) { + const { status, statusText, headers } = error.response; + const { data } = error.response as AxiosResponse; + + // Skip these errors as they are reflected in the UI + if (skipNotFound && (status === 404 || status === 410)) { + return { type: ALERT_NOOP }; + } + + // Rate limit errors + if (status === 429 && headers['x-ratelimit-reset']) { + return showAlert({ + title: messages.rateLimitedTitle, + message: messages.rateLimitedMessage, + values: { + retry_time: new Date(headers['x-ratelimit-reset'] as string), + }, + }); + } + + return showAlert({ + title: `${status}`, + message: data.error ?? statusText, + }); + } + + // An aborted request, e.g. due to reloading the browser window, it not really error + if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) { + return { type: ALERT_NOOP }; + } + + console.error(error); + + return showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }); +}; diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js index 9956059387..f9abc2e769 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -1,8 +1,5 @@ import api from '../api'; -import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; - export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; @@ -11,45 +8,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; -export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; -export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; -export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; - -export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; -export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; -export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; - -export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; -export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; -export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; - export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; -export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; -export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; -export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; - -export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; -export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; -export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; - -export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; -export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; -export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; - -export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; -export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; -export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; - -export const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; -export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; - -export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; -export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; -export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; - export const fetchList = id => (dispatch, getState) => { if (getState().getIn(['lists', id])) { return; @@ -100,89 +62,6 @@ export const fetchListsFail = error => ({ error, }); -export const submitListEditor = shouldReset => (dispatch, getState) => { - const listId = getState().getIn(['listEditor', 'listId']); - const title = getState().getIn(['listEditor', 'title']); - - if (listId === null) { - dispatch(createList(title, shouldReset)); - } else { - dispatch(updateList(listId, title, shouldReset)); - } -}; - -export const setupListEditor = listId => (dispatch, getState) => { - dispatch({ - type: LIST_EDITOR_SETUP, - list: getState().getIn(['lists', listId]), - }); - - dispatch(fetchListAccounts(listId)); -}; - -export const changeListEditorTitle = value => ({ - type: LIST_EDITOR_TITLE_CHANGE, - value, -}); - -export const createList = (title, shouldReset) => (dispatch) => { - dispatch(createListRequest()); - - api().post('/api/v1/lists', { title }).then(({ data }) => { - dispatch(createListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(createListFail(err))); -}; - -export const createListRequest = () => ({ - type: LIST_CREATE_REQUEST, -}); - -export const createListSuccess = list => ({ - type: LIST_CREATE_SUCCESS, - list, -}); - -export const createListFail = error => ({ - type: LIST_CREATE_FAIL, - error, -}); - -export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch) => { - dispatch(updateListRequest(id)); - - api().put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { - dispatch(updateListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(updateListFail(id, err))); -}; - -export const updateListRequest = id => ({ - type: LIST_UPDATE_REQUEST, - id, -}); - -export const updateListSuccess = list => ({ - type: LIST_UPDATE_SUCCESS, - list, -}); - -export const updateListFail = (id, error) => ({ - type: LIST_UPDATE_FAIL, - id, - error, -}); - -export const resetListEditor = () => ({ - type: LIST_EDITOR_RESET, -}); - export const deleteList = id => (dispatch) => { dispatch(deleteListRequest(id)); @@ -206,167 +85,3 @@ export const deleteListFail = (id, error) => ({ id, error, }); - -export const fetchListAccounts = listId => (dispatch) => { - dispatch(fetchListAccountsRequest(listId)); - - api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListAccountsSuccess(listId, data)); - }).catch(err => dispatch(fetchListAccountsFail(listId, err))); -}; - -export const fetchListAccountsRequest = id => ({ - type: LIST_ACCOUNTS_FETCH_REQUEST, - id, -}); - -export const fetchListAccountsSuccess = (id, accounts, next) => ({ - type: LIST_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, -}); - -export const fetchListAccountsFail = (id, error) => ({ - type: LIST_ACCOUNTS_FETCH_FAIL, - id, - error, -}); - -export const fetchListSuggestions = q => (dispatch) => { - const params = { - q, - resolve: false, - limit: 4, - following: true, - }; - - api().get('/api/v1/accounts/search', { params }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); -}; - -export const fetchListSuggestionsReady = (query, accounts) => ({ - type: LIST_EDITOR_SUGGESTIONS_READY, - query, - accounts, -}); - -export const clearListSuggestions = () => ({ - type: LIST_EDITOR_SUGGESTIONS_CLEAR, -}); - -export const changeListSuggestions = value => ({ - type: LIST_EDITOR_SUGGESTIONS_CHANGE, - value, -}); - -export const addToListEditor = accountId => (dispatch, getState) => { - dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const addToList = (listId, accountId) => (dispatch) => { - dispatch(addToListRequest(listId, accountId)); - - api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToListSuccess(listId, accountId))) - .catch(err => dispatch(addToListFail(listId, accountId, err))); -}; - -export const addToListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_REQUEST, - listId, - accountId, -}); - -export const addToListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_SUCCESS, - listId, - accountId, -}); - -export const addToListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_ADD_FAIL, - listId, - accountId, - error, -}); - -export const removeFromListEditor = accountId => (dispatch, getState) => { - dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const removeFromList = (listId, accountId) => (dispatch) => { - dispatch(removeFromListRequest(listId, accountId)); - - api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromListSuccess(listId, accountId))) - .catch(err => dispatch(removeFromListFail(listId, accountId, err))); -}; - -export const removeFromListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_REQUEST, - listId, - accountId, -}); - -export const removeFromListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_SUCCESS, - listId, - accountId, -}); - -export const removeFromListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_REMOVE_FAIL, - listId, - accountId, - error, -}); - -export const resetListAdder = () => ({ - type: LIST_ADDER_RESET, -}); - -export const setupListAdder = accountId => (dispatch, getState) => { - dispatch({ - type: LIST_ADDER_SETUP, - account: getState().getIn(['accounts', accountId]), - }); - dispatch(fetchLists()); - dispatch(fetchAccountLists(accountId)); -}; - -export const fetchAccountLists = accountId => (dispatch) => { - dispatch(fetchAccountListsRequest(accountId)); - - api().get(`/api/v1/accounts/${accountId}/lists`) - .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountListsFail(accountId, err))); -}; - -export const fetchAccountListsRequest = id => ({ - type:LIST_ADDER_LISTS_FETCH_REQUEST, - id, -}); - -export const fetchAccountListsSuccess = (id, lists) => ({ - type: LIST_ADDER_LISTS_FETCH_SUCCESS, - id, - lists, -}); - -export const fetchAccountListsFail = (id, err) => ({ - type: LIST_ADDER_LISTS_FETCH_FAIL, - id, - err, -}); - -export const addToListAdder = listId => (dispatch, getState) => { - dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId']))); -}; - -export const removeFromListAdder = listId => (dispatch, getState) => { - dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId']))); -}; diff --git a/app/javascript/mastodon/actions/lists_typed.ts b/app/javascript/mastodon/actions/lists_typed.ts new file mode 100644 index 0000000000..ccc5c11c89 --- /dev/null +++ b/app/javascript/mastodon/actions/lists_typed.ts @@ -0,0 +1,13 @@ +import { apiCreate, apiUpdate } from 'mastodon/api/lists'; +import type { List } from 'mastodon/models/list'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const createList = createDataLoadingThunk( + 'list/create', + (list: Partial) => apiCreate(list), +); + +export const updateList = createDataLoadingThunk( + 'list/update', + (list: Partial) => apiUpdate(list), +); diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts index 0b3280c212..251546cb9a 100644 --- a/app/javascript/mastodon/actions/markers.ts +++ b/app/javascript/mastodon/actions/markers.ts @@ -37,8 +37,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk( }); return; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if ('navigator' && 'sendBeacon' in navigator) { + } else if ('sendBeacon' in navigator) { // Failing that, we can use sendBeacon, but we have to encode the data as // FormData for DoorKeeper to recognize the token. const formData = new FormData(); diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index b40b04f8cc..aa7f50da4e 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -8,6 +8,7 @@ import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; import type { ApiNotificationGroupJSON, ApiNotificationJSON, + NotificationType, } from 'mastodon/api_types/notifications'; import { allNotificationTypes } from 'mastodon/api_types/notifications'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; @@ -15,6 +16,7 @@ import { usePendingItems } from 'mastodon/initial_state'; import type { NotificationGap } from 'mastodon/reducers/notification_groups'; import { selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsGroupFollows, selectSettingsNotificationsQuickFilterActive, selectSettingsNotificationsShows, } from 'mastodon/selectors/settings'; @@ -68,13 +70,19 @@ function dispatchAssociatedRecords( dispatch(importFetchedStatuses(fetchedStatuses)); } -const supportedGroupedNotificationTypes = ['favourite', 'reblog']; +function selectNotificationGroupedTypes(state: RootState) { + const types: NotificationType[] = ['favourite', 'reblog']; + + if (selectSettingsNotificationsGroupFollows(state)) types.push('follow'); + + return types; +} export const fetchNotifications = createDataLoadingThunk( 'notificationGroups/fetch', async (_params, { getState }) => apiFetchNotificationGroups({ - grouped_types: supportedGroupedNotificationTypes, + grouped_types: selectNotificationGroupedTypes(getState()), exclude_types: getExcludedTypes(getState()), }), ({ notifications, accounts, statuses }, { dispatch }) => { @@ -98,7 +106,7 @@ export const fetchNotificationsGap = createDataLoadingThunk( 'notificationGroups/fetchGap', async (params: { gap: NotificationGap }, { getState }) => apiFetchNotificationGroups({ - grouped_types: supportedGroupedNotificationTypes, + grouped_types: selectNotificationGroupedTypes(getState()), max_id: params.gap.maxId, exclude_types: getExcludedTypes(getState()), }), @@ -115,7 +123,7 @@ export const pollRecentNotifications = createDataLoadingThunk( 'notificationGroups/pollRecentNotifications', async (_params, { getState }) => { return apiFetchNotificationGroups({ - grouped_types: supportedGroupedNotificationTypes, + grouped_types: selectNotificationGroupedTypes(getState()), max_id: undefined, exclude_types: getExcludedTypes(getState()), // In slow mode, we don't want to include notifications that duplicate the already-displayed ones @@ -133,6 +141,9 @@ export const pollRecentNotifications = createDataLoadingThunk( return { notifications }; }, + { + useLoadingBar: false, + }, ); export const processNewNotificationForGroups = createAppAsyncThunk( @@ -164,7 +175,10 @@ export const processNewNotificationForGroups = createAppAsyncThunk( dispatchAssociatedRecords(dispatch, [notification]); - return notification; + return { + notification, + groupedTypes: selectNotificationGroupedTypes(state), + }; }, ); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index b6fc31fae8..30e643363a 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -106,12 +106,13 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti dispatch(processNewNotificationForGroups(notificationJSON)); break; } - case 'notifications_merged': + case 'notifications_merged': { const state = getState(); if (state.notifications.top || !state.notifications.mounted) dispatch(expandNotifications({ forceLoad: true, maxId: undefined })); dispatch(refreshStaleNotificationGroups()); break; + } case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js deleted file mode 100644 index 258ffa901d..0000000000 --- a/app/javascript/mastodon/actions/suggestions.js +++ /dev/null @@ -1,58 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; -export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; -export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; - -export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; - -export function fetchSuggestions(withRelationships = false) { - return (dispatch) => { - dispatch(fetchSuggestionsRequest()); - - api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => { - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchSuggestionsSuccess(response.data)); - - if (withRelationships) { - dispatch(fetchRelationships(response.data.map(item => item.account.id))); - } - }).catch(error => dispatch(fetchSuggestionsFail(error))); - }; -} - -export function fetchSuggestionsRequest() { - return { - type: SUGGESTIONS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchSuggestionsSuccess(suggestions) { - return { - type: SUGGESTIONS_FETCH_SUCCESS, - suggestions, - skipLoading: true, - }; -} - -export function fetchSuggestionsFail(error) { - return { - type: SUGGESTIONS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} - -export const dismissSuggestion = accountId => (dispatch) => { - dispatch({ - type: SUGGESTIONS_DISMISS, - id: accountId, - }); - - api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {}); -}; diff --git a/app/javascript/mastodon/actions/suggestions.ts b/app/javascript/mastodon/actions/suggestions.ts new file mode 100644 index 0000000000..0eadfa6b47 --- /dev/null +++ b/app/javascript/mastodon/actions/suggestions.ts @@ -0,0 +1,24 @@ +import { + apiGetSuggestions, + apiDeleteSuggestion, +} from 'mastodon/api/suggestions'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const fetchSuggestions = createDataLoadingThunk( + 'suggestions/fetch', + () => apiGetSuggestions(20), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data.map((x) => x.account))); + dispatch(fetchRelationships(data.map((x) => x.account.id))); + + return data; + }, +); + +export const dismissSuggestion = createDataLoadingThunk( + 'suggestions/dismiss', + ({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId), +); diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js index d18d7e514f..6e0c95288a 100644 --- a/app/javascript/mastodon/actions/tags.js +++ b/app/javascript/mastodon/actions/tags.js @@ -1,9 +1,5 @@ import api, { getLinks } from '../api'; -export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; -export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; -export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; - export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; @@ -12,39 +8,6 @@ export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUES export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; -export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; -export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; -export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; - -export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; -export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; -export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; - -export const fetchHashtag = name => (dispatch) => { - dispatch(fetchHashtagRequest()); - - api().get(`/api/v1/tags/${name}`).then(({ data }) => { - dispatch(fetchHashtagSuccess(name, data)); - }).catch(err => { - dispatch(fetchHashtagFail(err)); - }); -}; - -export const fetchHashtagRequest = () => ({ - type: HASHTAG_FETCH_REQUEST, -}); - -export const fetchHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FETCH_SUCCESS, - name, - tag, -}); - -export const fetchHashtagFail = error => ({ - type: HASHTAG_FETCH_FAIL, - error, -}); - export const fetchFollowedHashtags = () => (dispatch) => { dispatch(fetchFollowedHashtagsRequest()); @@ -116,57 +79,3 @@ export function expandFollowedHashtagsFail(error) { error, }; } - -export const followHashtag = name => (dispatch) => { - dispatch(followHashtagRequest(name)); - - api().post(`/api/v1/tags/${name}/follow`).then(({ data }) => { - dispatch(followHashtagSuccess(name, data)); - }).catch(err => { - dispatch(followHashtagFail(name, err)); - }); -}; - -export const followHashtagRequest = name => ({ - type: HASHTAG_FOLLOW_REQUEST, - name, -}); - -export const followHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FOLLOW_SUCCESS, - name, - tag, -}); - -export const followHashtagFail = (name, error) => ({ - type: HASHTAG_FOLLOW_FAIL, - name, - error, -}); - -export const unfollowHashtag = name => (dispatch) => { - dispatch(unfollowHashtagRequest(name)); - - api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { - dispatch(unfollowHashtagSuccess(name, data)); - }).catch(err => { - dispatch(unfollowHashtagFail(name, err)); - }); -}; - -export const unfollowHashtagRequest = name => ({ - type: HASHTAG_UNFOLLOW_REQUEST, - name, -}); - -export const unfollowHashtagSuccess = (name, tag) => ({ - type: HASHTAG_UNFOLLOW_SUCCESS, - name, - tag, -}); - -export const unfollowHashtagFail = (name, error) => ({ - type: HASHTAG_UNFOLLOW_FAIL, - name, - error, -}); diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts new file mode 100644 index 0000000000..6dca32fd84 --- /dev/null +++ b/app/javascript/mastodon/actions/tags_typed.ts @@ -0,0 +1,17 @@ +import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const fetchHashtag = createDataLoadingThunk( + 'tags/fetch', + ({ tagId }: { tagId: string }) => apiGetTag(tagId), +); + +export const followHashtag = createDataLoadingThunk( + 'tags/follow', + ({ tagId }: { tagId: string }) => apiFollowTag(tagId), +); + +export const unfollowHashtag = createDataLoadingThunk( + 'tags/unfollow', + ({ tagId }: { tagId: string }) => apiUnfollowTag(tagId), +); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 51cbe0b695..f0663ded40 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -68,6 +68,7 @@ export async function apiRequest( method: Method, url: string, args: { + signal?: AbortSignal; params?: RequestParamsOrData; data?: RequestParamsOrData; timeout?: number; diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index bd1757e827..717010ba74 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -5,3 +5,16 @@ export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { comment: value, }); + +export const apiFollowAccount = ( + id: string, + params?: { + reblogs: boolean; + }, +) => + apiRequestPost(`v1/accounts/${id}/follow`, { + ...params, + }); + +export const apiUnfollowAccount = (id: string) => + apiRequestPost(`v1/accounts/${id}/unfollow`); diff --git a/app/javascript/mastodon/api/lists.ts b/app/javascript/mastodon/api/lists.ts new file mode 100644 index 0000000000..a5586eb6d4 --- /dev/null +++ b/app/javascript/mastodon/api/lists.ts @@ -0,0 +1,32 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { ApiListJSON } from 'mastodon/api_types/lists'; + +export const apiCreate = (list: Partial) => + apiRequestPost('v1/lists', list); + +export const apiUpdate = (list: Partial) => + apiRequestPut(`v1/lists/${list.id}`, list); + +export const apiGetAccounts = (listId: string) => + apiRequestGet(`v1/lists/${listId}/accounts`, { + limit: 0, + }); + +export const apiGetAccountLists = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/lists`); + +export const apiAddAccountToList = (listId: string, accountId: string) => + apiRequestPost(`v1/lists/${listId}/accounts`, { + account_ids: [accountId], + }); + +export const apiRemoveAccountFromList = (listId: string, accountId: string) => + apiRequestDelete(`v1/lists/${listId}/accounts`, { + account_ids: [accountId], + }); diff --git a/app/javascript/mastodon/api/suggestions.ts b/app/javascript/mastodon/api/suggestions.ts new file mode 100644 index 0000000000..d4817698cc --- /dev/null +++ b/app/javascript/mastodon/api/suggestions.ts @@ -0,0 +1,8 @@ +import { apiRequestGet, apiRequestDelete } from 'mastodon/api'; +import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions'; + +export const apiGetSuggestions = (limit: number) => + apiRequestGet('v2/suggestions', { limit }); + +export const apiDeleteSuggestion = (accountId: string) => + apiRequestDelete(`v1/suggestions/${accountId}`); diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts new file mode 100644 index 0000000000..2cb802800c --- /dev/null +++ b/app/javascript/mastodon/api/tags.ts @@ -0,0 +1,11 @@ +import { apiRequestPost, apiRequestGet } from 'mastodon/api'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export const apiGetTag = (tagId: string) => + apiRequestGet(`v1/tags/${tagId}`); + +export const apiFollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/follow`); + +export const apiUnfollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/unfollow`); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 5bf3e64288..fdbd7523fc 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -13,7 +13,7 @@ export interface ApiAccountRoleJSON { } // See app/serializers/rest/account_serializer.rb -export interface ApiAccountJSON { +export interface BaseApiAccountJSON { acct: string; avatar: string; avatar_static: string; @@ -45,3 +45,12 @@ export interface ApiAccountJSON { memorial?: boolean; hide_collections: boolean; } + +// See app/serializers/rest/muted_account_serializer.rb +export interface ApiMutedAccountJSON extends BaseApiAccountJSON { + mute_expires_at?: string | null; +} + +// For now, we have the same type representing both `Account` and `MutedAccount` +// objects, but we should refactor this in the future. +export type ApiAccountJSON = ApiMutedAccountJSON; diff --git a/app/javascript/mastodon/api_types/lists.ts b/app/javascript/mastodon/api_types/lists.ts new file mode 100644 index 0000000000..6984cf9b19 --- /dev/null +++ b/app/javascript/mastodon/api_types/lists.ts @@ -0,0 +1,10 @@ +// See app/serializers/rest/list_serializer.rb + +export type RepliesPolicyType = 'list' | 'followed' | 'none'; + +export interface ApiListJSON { + id: string; + title: string; + exclusive: boolean; + replies_policy: RepliesPolicyType; +} diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 28ba7eb5c2..190d8c8396 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -20,6 +20,7 @@ export const allNotificationTypes = [ 'admin.report', 'moderation_warning', 'severed_relationships', + 'annual_report', ]; export type NotificationWithStatusType = @@ -37,7 +38,8 @@ export type NotificationType = | 'moderation_warning' | 'severed_relationships' | 'admin.sign_up' - | 'admin.report'; + | 'admin.report' + | 'annual_report'; export interface BaseNotificationJSON { id: string; @@ -130,6 +132,15 @@ interface AccountRelationshipSeveranceNotificationJSON event: ApiAccountRelationshipSeveranceEventJSON; } +export interface ApiAnnualReportEventJSON { + year: string; +} + +interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'annual_report'; + annual_report: ApiAnnualReportEventJSON; +} + export type ApiNotificationJSON = | SimpleNotificationJSON | ReportNotificationJSON @@ -142,7 +153,8 @@ export type ApiNotificationGroupJSON = | ReportNotificationGroupJSON | AccountRelationshipSeveranceNotificationGroupJSON | NotificationGroupWithStatusJSON - | ModerationWarningNotificationGroupJSON; + | ModerationWarningNotificationGroupJSON + | AnnualReportNotificationGroupJSON; export interface ApiNotificationGroupsResultJSON { accounts: ApiAccountJSON[]; diff --git a/app/javascript/mastodon/api_types/suggestions.ts b/app/javascript/mastodon/api_types/suggestions.ts new file mode 100644 index 0000000000..7d91daf901 --- /dev/null +++ b/app/javascript/mastodon/api_types/suggestions.ts @@ -0,0 +1,13 @@ +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; + +export type ApiSuggestionSourceJSON = + | 'featured' + | 'most_followed' + | 'most_interactions' + | 'similar_to_recently_followed' + | 'friends_of_friends'; + +export interface ApiSuggestionJSON { + sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]]; + account: ApiAccountJSON; +} diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts new file mode 100644 index 0000000000..0c16c8bd28 --- /dev/null +++ b/app/javascript/mastodon/api_types/tags.ts @@ -0,0 +1,13 @@ +interface ApiHistoryJSON { + day: string; + accounts: string; + uses: string; +} + +export interface ApiHashtagJSON { + id: string; + name: string; + url: string; + history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; + following?: boolean; +} diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap index 2f0a2de324..124b50d8c7 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap @@ -2,7 +2,7 @@ exports[` Autoplay renders a animated avatar 1`] = `
Autoplay renders a animated avatar 1`] = ` >
@@ -21,7 +23,7 @@ exports[` Autoplay renders a animated avatar 1`] = ` exports[` Still renders a still avatar 1`] = `
Still renders a still avatar 1`] = ` >
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx deleted file mode 100644 index 265c68697b..0000000000 --- a/app/javascript/mastodon/components/account.jsx +++ /dev/null @@ -1,181 +0,0 @@ -import PropTypes from 'prop-types'; -import { useCallback } from 'react'; - -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; -import { Link } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import { EmptyAccount } from 'mastodon/components/empty_account'; -import { ShortNumber } from 'mastodon/components/short_number'; -import { VerifiedBadge } from 'mastodon/components/verified_badge'; - -import DropdownMenuContainer from '../containers/dropdown_menu_container'; -import { me } from '../initial_state'; - -import { Avatar } from './avatar'; -import { Button } from './button'; -import { FollowersCounter } from './counters'; -import { DisplayName } from './display_name'; -import { RelativeTimestamp } from './relative_timestamp'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' }, - unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' }, - mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, - block: { id: 'account.block_short', defaultMessage: 'Block' }, - more: { id: 'status.more', defaultMessage: 'More' }, -}); - -const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => { - const intl = useIntl(); - - const handleFollow = useCallback(() => { - onFollow(account); - }, [onFollow, account]); - - const handleBlock = useCallback(() => { - onBlock(account); - }, [onBlock, account]); - - const handleMute = useCallback(() => { - onMute(account); - }, [onMute, account]); - - const handleMuteNotifications = useCallback(() => { - onMuteNotifications(account, true); - }, [onMuteNotifications, account]); - - const handleUnmuteNotifications = useCallback(() => { - onMuteNotifications(account, false); - }, [onMuteNotifications, account]); - - if (!account) { - return ; - } - - if (hidden) { - return ( - <> - {account.get('display_name')} - {account.get('username')} - - ); - } - - let buttons; - - if (account.get('id') !== me && account.get('relationship', null) !== null) { - const following = account.getIn(['relationship', 'following']); - const requested = account.getIn(['relationship', 'requested']); - const blocking = account.getIn(['relationship', 'blocking']); - const muting = account.getIn(['relationship', 'muting']); - - if (requested) { - buttons = + + + {({ props }) => ( +
+
+

+ +

+

{description}

+
+
+ )} +
+ + ); +}; diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx index 8b16296c2c..f61d9676de 100644 --- a/app/javascript/mastodon/components/avatar.tsx +++ b/app/javascript/mastodon/components/avatar.tsx @@ -1,10 +1,11 @@ +import { useState, useCallback } from 'react'; + import classNames from 'classnames'; +import { useHovering } from 'mastodon/../hooks/useHovering'; +import { autoPlayGif } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; -import { useHovering } from '../../hooks/useHovering'; -import { autoPlayGif } from '../initial_state'; - interface Props { account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there size: number; @@ -25,6 +26,8 @@ export const Avatar: React.FC = ({ counterBorderColor, }) => { const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); const style = { ...styleFromParent, @@ -37,16 +40,28 @@ export const Avatar: React.FC = ({ ? account?.get('avatar') : account?.get('avatar_static'); + const handleLoad = useCallback(() => { + setLoading(false); + }, [setLoading]); + + const handleError = useCallback(() => { + setError(true); + }, [setError]); + return (
- {src && } + {src && !error && ( + + )} + {counter && (
) => void; - label: React.ReactNode; + checked?: boolean; + indeterminate?: boolean; + name?: string; + onChange?: (event: React.ChangeEvent) => void; + label?: React.ReactNode; } export const CheckBox: React.FC = ({ @@ -30,6 +30,7 @@ export const CheckBox: React.FC = ({ value={value} checked={checked} onChange={onChange} + readOnly={!onChange} /> = ({ )} - {label} + {label && {label}} ); }; diff --git a/app/javascript/mastodon/components/column.jsx b/app/javascript/mastodon/components/column.jsx deleted file mode 100644 index abc87a57e5..0000000000 --- a/app/javascript/mastodon/components/column.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { supportsPassiveEvents } from 'detect-passive-events'; - -import { scrollTop } from '../scroll'; - -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; - -export default class Column extends PureComponent { - - static propTypes = { - children: PropTypes.node, - label: PropTypes.string, - bindToDocument: PropTypes.bool, - }; - - scrollTop () { - let scrollable = null; - - if (this.props.bindToDocument) { - scrollable = document.scrollingElement; - } else { - scrollable = this.node.querySelector('.scrollable'); - } - - if (!scrollable) { - return; - } - - this._interruptScrollAnimation = scrollTop(scrollable); - } - - handleWheel = () => { - if (typeof this._interruptScrollAnimation !== 'function') { - return; - } - - this._interruptScrollAnimation(); - }; - - setRef = c => { - this.node = c; - }; - - componentDidMount () { - if (this.props.bindToDocument) { - document.addEventListener('wheel', this.handleWheel, listenerOptions); - } else { - this.node.addEventListener('wheel', this.handleWheel, listenerOptions); - } - } - - componentWillUnmount () { - if (this.props.bindToDocument) { - document.removeEventListener('wheel', this.handleWheel, listenerOptions); - } else { - this.node.removeEventListener('wheel', this.handleWheel, listenerOptions); - } - } - - render () { - const { label, children } = this.props; - - return ( -
- {children} -
- ); - } - -} diff --git a/app/javascript/mastodon/components/column.tsx b/app/javascript/mastodon/components/column.tsx new file mode 100644 index 0000000000..01c75d85c0 --- /dev/null +++ b/app/javascript/mastodon/components/column.tsx @@ -0,0 +1,52 @@ +import { forwardRef, useRef, useImperativeHandle } from 'react'; +import type { Ref } from 'react'; + +import { scrollTop } from 'mastodon/scroll'; + +export interface ColumnRef { + scrollTop: () => void; + node: HTMLDivElement | null; +} + +interface ColumnProps { + children?: React.ReactNode; + label?: string; + bindToDocument?: boolean; +} + +export const Column = forwardRef( + ({ children, label, bindToDocument }, ref: Ref) => { + const nodeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + node: nodeRef.current, + + scrollTop() { + let scrollable = null; + + if (bindToDocument) { + scrollable = document.scrollingElement; + } else { + scrollable = nodeRef.current?.querySelector('.scrollable'); + } + + if (!scrollable) { + return; + } + + scrollTop(scrollable); + }, + })); + + return ( +
+ {children} +
+ ); + }, +); + +Column.displayName = 'Column'; + +// eslint-disable-next-line import/no-default-export +export default Column; diff --git a/app/javascript/mastodon/components/column_back_button.tsx b/app/javascript/mastodon/components/column_back_button.tsx index af38c1e110..aad355d710 100644 --- a/app/javascript/mastodon/components/column_back_button.tsx +++ b/app/javascript/mastodon/components/column_back_button.tsx @@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) { }, [history, onClick]); } -export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({ +export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({ onClick, }) => { const handleClick = useHandleClick(onClick); diff --git a/app/javascript/mastodon/components/column_search_header.tsx b/app/javascript/mastodon/components/column_search_header.tsx new file mode 100644 index 0000000000..90b6c4d89f --- /dev/null +++ b/app/javascript/mastodon/components/column_search_header.tsx @@ -0,0 +1,67 @@ +import { useCallback, useState, useEffect, useRef } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +export const ColumnSearchHeader: React.FC<{ + onBack: () => void; + onSubmit: (value: string) => void; + onActivate: () => void; + placeholder: string; + active: boolean; +}> = ({ onBack, onActivate, onSubmit, placeholder, active }) => { + const inputRef = useRef(null); + const [value, setValue] = useState(''); + + useEffect(() => { + if (!active) { + setValue(''); + } + }, [active]); + + const handleChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setValue(value); + onSubmit(value); + }, + [setValue, onSubmit], + ); + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onBack(); + inputRef.current?.blur(); + } + }, + [onBack], + ); + + const handleFocus = useCallback(() => { + onActivate(); + }, [onActivate]); + + const handleSubmit = useCallback(() => { + onSubmit(value); + }, [onSubmit, value]); + + return ( +
+ + + {active && ( + + )} +
+ ); +}; diff --git a/app/javascript/mastodon/components/content_warning.tsx b/app/javascript/mastodon/components/content_warning.tsx index df8afca74d..c1c879b55d 100644 --- a/app/javascript/mastodon/components/content_warning.tsx +++ b/app/javascript/mastodon/components/content_warning.tsx @@ -8,7 +8,7 @@ export const ContentWarning: React.FC<{

diff --git a/app/javascript/mastodon/components/empty_account.tsx b/app/javascript/mastodon/components/empty_account.tsx deleted file mode 100644 index a4a6b7f823..0000000000 --- a/app/javascript/mastodon/components/empty_account.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import classNames from 'classnames'; - -import { DisplayName } from 'mastodon/components/display_name'; -import { Skeleton } from 'mastodon/components/skeleton'; - -interface Props { - size?: number; - minimal?: boolean; -} - -export const EmptyAccount: React.FC = ({ - size = 46, - minimal = false, -}) => { - return ( -

-
-
-
- -
- -
- - -
-
-
-
- ); -}; diff --git a/app/javascript/mastodon/components/filter_warning.tsx b/app/javascript/mastodon/components/filter_warning.tsx index 4305e43038..5eaaac4ba3 100644 --- a/app/javascript/mastodon/components/filter_warning.tsx +++ b/app/javascript/mastodon/components/filter_warning.tsx @@ -10,13 +10,16 @@ export const FilterWarning: React.FC<{

{chunks}, + }} />

diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 46314af309..faf9d8bdb8 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -99,7 +99,12 @@ export const FollowButton: React.FC<{ return ( diff --git a/app/javascript/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx index e7fa5e6f9a..b0d88fe8f9 100644 --- a/app/javascript/mastodon/components/modal_root.jsx +++ b/app/javascript/mastodon/components/modal_root.jsx @@ -13,11 +13,14 @@ class ModalRoot extends PureComponent { static propTypes = { children: PropTypes.node, onClose: PropTypes.func.isRequired, - backgroundColor: PropTypes.shape({ - r: PropTypes.number, - g: PropTypes.number, - b: PropTypes.number, - }), + backgroundColor: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + r: PropTypes.number, + g: PropTypes.number, + b: PropTypes.number, + }), + ]), ignoreFocus: PropTypes.bool, ...WithOptionalRouterPropTypes, }; @@ -141,14 +144,17 @@ class ModalRoot extends PureComponent { let backgroundColor = null; - if (this.props.backgroundColor) { - backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 }); + if (this.props.backgroundColor && typeof this.props.backgroundColor === 'string') { + backgroundColor = this.props.backgroundColor; + } else if (this.props.backgroundColor) { + const darkenedColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 }); + backgroundColor = `rgb(${darkenedColor.r}, ${darkenedColor.g}, ${darkenedColor.b})`; } return (
-
+
{children}
diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx index 7b836f00b1..06b09f5b35 100644 --- a/app/javascript/mastodon/components/poll.jsx +++ b/app/javascript/mastodon/components/poll.jsx @@ -41,12 +41,14 @@ const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { class Poll extends ImmutablePureComponent { static propTypes = { identity: identityContextPropShape, - poll: ImmutablePropTypes.map, + poll: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map.isRequired, lang: PropTypes.string, intl: PropTypes.object.isRequired, disabled: PropTypes.bool, refresh: PropTypes.func, onVote: PropTypes.func, + onInteractionModal: PropTypes.func, }; state = { @@ -117,7 +119,11 @@ class Poll extends ImmutablePureComponent { return; } - this.props.onVote(Object.keys(this.state.selected)); + if (this.props.identity.signedIn) { + this.props.onVote(Object.keys(this.state.selected)); + } else { + this.props.onInteractionModal('vote', this.props.status); + } }; handleRefresh = () => { @@ -232,7 +238,7 @@ class Poll extends ImmutablePureComponent {
- {!showResults && } + {!showResults && } {!showResults && <> · } {showResults && !this.props.disabled && <> · } {votesCount} diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 29439fdf55..35cd86ea1a 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -80,6 +80,7 @@ class ScrollableList extends PureComponent { children: PropTypes.node, bindToDocument: PropTypes.bool, preventScroll: PropTypes.bool, + footer: PropTypes.node, }; static defaultProps = { @@ -324,7 +325,7 @@ class ScrollableList extends PureComponent { }; render () { - const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = Children.count(children); @@ -342,11 +343,13 @@ class ScrollableList extends PureComponent {
+ + {footer}
); } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = ( -
+
{prepend} @@ -375,6 +378,8 @@ class ScrollableList extends PureComponent { {!hasMore && append}
+ + {footer}
); } else { @@ -385,6 +390,8 @@ class ScrollableList extends PureComponent {
{emptyMessage}
+ + {footer}
); } diff --git a/app/javascript/mastodon/components/server_banner.jsx b/app/javascript/mastodon/components/server_banner.jsx index baa220af5e..b6ea01997b 100644 --- a/app/javascript/mastodon/components/server_banner.jsx +++ b/app/javascript/mastodon/components/server_banner.jsx @@ -8,10 +8,10 @@ import { Link } from 'react-router-dom'; import { connect } from 'react-redux'; import { fetchServer } from 'mastodon/actions/server'; +import { Account } from 'mastodon/components/account'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; -import Account from 'mastodon/containers/account_container'; import { domain } from 'mastodon/initial_state'; const messages = defineMessages({ diff --git a/app/javascript/mastodon/components/short_number.tsx b/app/javascript/mastodon/components/short_number.tsx index a0b523aaad..37201a5e1d 100644 --- a/app/javascript/mastodon/components/short_number.tsx +++ b/app/javascript/mastodon/components/short_number.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import type { JSX } from 'react'; import { FormattedMessage, FormattedNumber } from 'react-intl'; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 6c32fd245d..cf6fe86c3d 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -3,6 +3,8 @@ import PropTypes from 'prop-types'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -164,32 +166,18 @@ class Status extends ImmutablePureComponent { }; handleClick = e => { - if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.handleHotkeyOpen(e); + }; + + handleMouseUp = e => { + // Only handle clicks on the empty space above the content + + if (e.target !== e.currentTarget && e.detail >= 1) { return; } - if (e) { - e.preventDefault(); - } - - this.handleHotkeyOpen(); - }; - - handlePrependAccountClick = e => { - this.handleAccountClick(e, false); - }; - - handleAccountClick = (e, proper = true) => { - if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { - return; - } - - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - - this._openProfile(proper); + this.handleClick(e); }; handleExpandedToggle = () => { @@ -287,7 +275,7 @@ class Status extends ImmutablePureComponent { this.props.onMention(this._properStatus().get('account')); }; - handleHotkeyOpen = () => { + handleHotkeyOpen = (e) => { if (this.props.onClick) { this.props.onClick(); return; @@ -300,7 +288,13 @@ class Status extends ImmutablePureComponent { return; } - history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); + const path = `/@${status.getIn(['account', 'acct'])}/${status.get('id')}`; + + if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) { + history.push(path); + } else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) { + window.open(path, '_blank', 'noreferrer noopener'); + } }; handleHotkeyOpenProfile = () => { @@ -394,17 +388,6 @@ class Status extends ImmutablePureComponent { let media, statusAvatar, prepend, rebloggedByText; - if (hidden) { - return ( - -
- {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} - {status.get('content')} -
-
- ); - } - const connectUp = previousId && previousId === status.get('in_reply_to_id'); const connectToRoot = rootId && rootId === status.get('in_reply_to_id'); const connectReply = nextInReplyToId && nextInReplyToId === status.get('id'); @@ -423,7 +406,7 @@ class Status extends ImmutablePureComponent { prepend = (
- }} /> + }} />
); @@ -444,12 +427,44 @@ class Status extends ImmutablePureComponent { ); } + const expanded = (!matchedFilters || this.state.showDespiteFilter) && (!status.get('hidden') || status.get('spoiler_text').length === 0); + + if (hidden) { + return ( + +
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {status.get('spoiler_text').length > 0 && ({status.get('spoiler_text')})} + {expanded && {status.get('content')}} +
+
+ ); + } + if (pictureInPicture.get('inUse')) { media = ; } else if (status.get('media_attachments').size > 0) { const language = status.getIn(['translation', 'language']) || status.get('language'); - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + if (['image', 'gifv', 'unknown'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) { + media = ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { const attachment = status.getIn(['media_attachments', 0]); const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); @@ -501,24 +516,6 @@ class Status extends ImmutablePureComponent { )} ); - } else { - media = ( - - {Component => ( - - )} - - ); } } else if (status.get('spoiler_text').length === 0 && status.get('card')) { media = ( @@ -538,7 +535,6 @@ class Status extends ImmutablePureComponent { } const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); - const expanded = (!matchedFilters || this.state.showDespiteFilter) && (!status.get('hidden') || status.get('spoiler_text').length === 0); return ( @@ -548,20 +544,19 @@ class Status extends ImmutablePureComponent {
{(connectReply || connectUp || connectToRoot) &&
} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
- + {matchedFilters && } diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 75531abf56..94cd7e3e07 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -264,7 +264,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick }); } - if (publicStatus && (signedIn || !isRemote)) { + if (publicStatus && !isRemote) { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); } diff --git a/app/javascript/mastodon/components/status_banner.tsx b/app/javascript/mastodon/components/status_banner.tsx index 8ff17a9b2e..d25c05d6db 100644 --- a/app/javascript/mastodon/components/status_banner.tsx +++ b/app/javascript/mastodon/components/status_banner.tsx @@ -1,8 +1,8 @@ import { FormattedMessage } from 'react-intl'; export enum BannerVariant { - Yellow = 'yellow', - Blue = 'blue', + Warning = 'warning', + Filter = 'filter', } export const StatusBanner: React.FC<{ @@ -11,9 +11,9 @@ export const StatusBanner: React.FC<{ expanded?: boolean; onClick?: () => void; }> = ({ children, variant, expanded, onClick }) => ( -
+ ) : variant === BannerVariant.Warning ? ( + ) : ( )} -
+ ); diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 3316be8350..6b06b938de 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -204,8 +204,8 @@ class StatusContent extends PureComponent { element = element.parentNode; } - if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) { - this.props.onClick(); + if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && e.detail >= 1 && this.props.onClick) { + this.props.onClick(e); } this.startXY = null; @@ -245,7 +245,7 @@ class StatusContent extends PureComponent { ); const poll = !!status.get('poll') && ( - + ); if (this.props.onClick) { diff --git a/app/javascript/mastodon/containers/account_container.jsx b/app/javascript/mastodon/containers/account_container.jsx deleted file mode 100644 index d34962fa4a..0000000000 --- a/app/javascript/mastodon/containers/account_container.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import { injectIntl } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { openModal } from 'mastodon/actions/modal'; - -import { - followAccount, - blockAccount, - unblockAccount, - muteAccount, - unmuteAccount, -} from '../actions/accounts'; -import { initMuteModal } from '../actions/mutes'; -import Account from '../components/account'; -import { makeGetAccount } from '../selectors'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, props) => ({ - account: getAccount(state, props.id), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch) => ({ - - onFollow (account) { - if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { - dispatch(openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } })); - } else { - dispatch(followAccount(account.get('id'))); - } - }, - - onBlock (account) { - if (account.getIn(['relationship', 'blocking'])) { - dispatch(unblockAccount(account.get('id'))); - } else { - dispatch(blockAccount(account.get('id'))); - } - }, - - onMute (account) { - if (account.getIn(['relationship', 'muting'])) { - dispatch(unmuteAccount(account.get('id'))); - } else { - dispatch(initMuteModal(account)); - } - }, - - - onMuteNotifications (account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); - }, -}); - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index bc9124c041..dc2a9648ff 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -15,6 +15,13 @@ const mapStateToProps = state => ({ openedViaKeyboard: state.dropdownMenu.keyboard, }); +/** + * @param {any} dispatch + * @param {Object} root0 + * @param {any} [root0.status] + * @param {any} root0.items + * @param {any} [root0.scrollKey] + */ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ onOpen(id, onItemClick, keyboard) { if (status) { diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js index 8482345431..db378cba7c 100644 --- a/app/javascript/mastodon/containers/poll_container.js +++ b/app/javascript/mastodon/containers/poll_container.js @@ -2,6 +2,7 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; +import { openModal } from 'mastodon/actions/modal'; import { fetchPoll, vote } from 'mastodon/actions/polls'; import Poll from 'mastodon/components/poll'; @@ -17,6 +18,17 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({ onVote (choices) { dispatch(vote(pollId, choices)); }, + + onInteractionModal (type, status) { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type, + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + } }); const mapStateToProps = (state, { pollId }) => ({ diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 65a36520d6..3b24a76368 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -13,11 +13,11 @@ import { connect } from 'react-redux'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react'; import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server'; +import { Account } from 'mastodon/components/account'; import Column from 'mastodon/components/column'; import { Icon } from 'mastodon/components/icon'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { Skeleton } from 'mastodon/components/skeleton'; -import Account from 'mastodon/containers/account_container'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; const messages = defineMessages({ diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.jsx b/app/javascript/mastodon/features/account_gallery/components/media_item.jsx deleted file mode 100644 index 087e775753..0000000000 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.jsx +++ /dev/null @@ -1,158 +0,0 @@ -import PropTypes from 'prop-types'; - -import classNames from 'classnames'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import AudiotrackIcon from '@/material-icons/400-24px/music_note.svg?react'; -import PlayArrowIcon from '@/material-icons/400-24px/play_arrow.svg?react'; -import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; -import { Blurhash } from 'mastodon/components/blurhash'; -import { Icon } from 'mastodon/components/icon'; -import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state'; - -export default class MediaItem extends ImmutablePureComponent { - - static propTypes = { - attachment: ImmutablePropTypes.map.isRequired, - displayWidth: PropTypes.number.isRequired, - onOpenMedia: PropTypes.func.isRequired, - }; - - state = { - visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all', - loaded: false, - }; - - handleImageLoad = () => { - this.setState({ loaded: true }); - }; - - handleMouseEnter = e => { - if (this.hoverToPlay()) { - e.target.play(); - } - }; - - handleMouseLeave = e => { - if (this.hoverToPlay()) { - e.target.pause(); - e.target.currentTime = 0; - } - }; - - hoverToPlay () { - return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1; - } - - handleClick = e => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - - if (this.state.visible) { - this.props.onOpenMedia(this.props.attachment); - } else { - this.setState({ visible: true }); - } - } - }; - - render () { - const { attachment, displayWidth } = this.props; - const { visible, loaded } = this.state; - - const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; - const height = width; - const status = attachment.get('status'); - const title = status.get('spoiler_text') || attachment.get('description'); - - let thumbnail, label, icon, content; - - if (!visible) { - icon = ( - - - - ); - } else { - if (['audio', 'video'].includes(attachment.get('type'))) { - content = ( - {attachment.get('description')} - ); - - if (attachment.get('type') === 'audio') { - label = ; - } else { - label = ; - } - } else if (attachment.get('type') === 'image') { - const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; - const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; - const x = ((focusX / 2) + .5) * 100; - const y = ((focusY / -2) + .5) * 100; - - content = ( - {attachment.get('description')} - ); - } else if (attachment.get('type') === 'gifv') { - content = ( -
+ ); + } + + const displayName = ( + + + + ), + }} + /> + + {label} + + ); + + return ( +
+ +
+ ); +}; diff --git a/app/javascript/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx new file mode 100644 index 0000000000..91f03330c0 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { + importFetchedStatuses, + importFetchedAccounts, +} from 'mastodon/actions/importer'; +import { apiRequestGet, apiRequestPost } from 'mastodon/api'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { me } from 'mastodon/initial_state'; +import type { Account } from 'mastodon/models/account'; +import type { AnnualReport as AnnualReportData } from 'mastodon/models/annual_report'; +import type { Status } from 'mastodon/models/status'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { Archetype } from './archetype'; +import { Followers } from './followers'; +import { HighlightedPost } from './highlighted_post'; +import { MostUsedHashtag } from './most_used_hashtag'; +import { NewPosts } from './new_posts'; +import { Percentile } from './percentile'; + +interface AnnualReportResponse { + annual_reports: AnnualReportData[]; + accounts: Account[]; + statuses: Status[]; +} + +export const AnnualReport: React.FC<{ + year: string; +}> = ({ year }) => { + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + const currentAccount = useAppSelector((state) => + me ? state.accounts.get(me) : undefined, + ); + const dispatch = useAppDispatch(); + + useEffect(() => { + setLoading(true); + + apiRequestGet(`v1/annual_reports/${year}`) + .then((data) => { + dispatch(importFetchedStatuses(data.statuses)); + dispatch(importFetchedAccounts(data.accounts)); + + setResponse(data); + setLoading(false); + + return apiRequestPost(`v1/annual_reports/${year}/read`); + }) + .catch(() => { + setLoading(false); + }); + }, [dispatch, year, setResponse, setLoading]); + + if (loading) { + return ; + } + + const report = response?.annual_reports[0]; + + if (!report) { + return null; + } + + return ( +
+
+

+ +

+

+ +

+
+ +
+ + + + + + +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/annual_report/most_used_app.tsx b/app/javascript/mastodon/features/annual_report/most_used_app.tsx new file mode 100644 index 0000000000..2d8c8aa582 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/most_used_app.tsx @@ -0,0 +1,29 @@ +import { FormattedMessage } from 'react-intl'; + +import type { NameAndCount } from 'mastodon/models/annual_report'; + +export const MostUsedApp: React.FC<{ + data: NameAndCount[]; +}> = ({ data }) => { + const app = data[0]; + + if (!app) { + return ( +
+ ); + } + + return ( +
+
+ {app.name} +
+
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx b/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx new file mode 100644 index 0000000000..4b59b89737 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx @@ -0,0 +1,30 @@ +import { FormattedMessage } from 'react-intl'; + +import type { NameAndCount } from 'mastodon/models/annual_report'; + +export const MostUsedHashtag: React.FC<{ + data: NameAndCount[]; +}> = ({ data }) => { + const hashtag = data[0]; + + return ( +
+
+ {hashtag ? ( + <>#{hashtag.name} + ) : ( + + )} +
+
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/annual_report/new_posts.tsx b/app/javascript/mastodon/features/annual_report/new_posts.tsx new file mode 100644 index 0000000000..9ead0176b2 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/new_posts.tsx @@ -0,0 +1,53 @@ +import { FormattedNumber, FormattedMessage } from 'react-intl'; + +import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react'; +import type { TimeSeriesMonth } from 'mastodon/models/annual_report'; + +export const NewPosts: React.FC<{ + data: TimeSeriesMonth[]; +}> = ({ data }) => { + const posts = data.reduce((sum, item) => sum + item.statuses, 0); + + return ( +
+ + + + + + + + + + + +
+ +
+
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/annual_report/percentile.tsx b/app/javascript/mastodon/features/annual_report/percentile.tsx new file mode 100644 index 0000000000..0cfa7d18b9 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/percentile.tsx @@ -0,0 +1,56 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { FormattedMessage, FormattedNumber } from 'react-intl'; + +import { domain } from 'mastodon/initial_state'; +import type { Percentiles } from 'mastodon/models/annual_report'; + +export const Percentile: React.FC<{ + data: Percentiles; +}> = ({ data }) => { + const percentile = data.statuses; + + return ( +
+ ( +
+ {str} +
+ ), + percentage: () => ( +
+ +
+ ), + bottomLabel: (str) => ( +
+
+ {str} +
+ + {percentile < 6 && ( +
+ +
+ )} +
+ ), + + domain, + }} + > + {(message) => <>{message}} +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/blocks/index.jsx b/app/javascript/mastodon/features/blocks/index.jsx index 1a631d3d07..57f86042e3 100644 --- a/app/javascript/mastodon/features/blocks/index.jsx +++ b/app/javascript/mastodon/features/blocks/index.jsx @@ -9,11 +9,11 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react'; +import { Account } from 'mastodon/components/account'; import { fetchBlocks, expandBlocks } from '../../actions/blocks'; import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; const messages = defineMessages({ @@ -70,7 +70,7 @@ class Blocks extends ImmutablePureComponent { bindToDocument={!multiColumn} > {accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.jsx b/app/javascript/mastodon/features/community_timeline/components/column_settings.jsx index 15381b589d..73927b612d 100644 --- a/app/javascript/mastodon/features/community_timeline/components/column_settings.jsx +++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.jsx @@ -21,9 +21,11 @@ class ColumnSettings extends PureComponent { return (
-
- } /> -
+
+
+ } /> +
+
); } diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.jsx b/app/javascript/mastodon/features/compose/components/navigation_bar.jsx index ec5578eef3..38382b0ca0 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.jsx +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.jsx @@ -6,7 +6,7 @@ import { useSelector, useDispatch } from 'react-redux'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import { cancelReplyCompose } from 'mastodon/actions/compose'; -import Account from 'mastodon/components/account'; +import { Account } from 'mastodon/components/account'; import { IconButton } from 'mastodon/components/icon_button'; import { me } from 'mastodon/initial_state'; @@ -20,7 +20,6 @@ const messages = defineMessages({ export const NavigationBar = () => { const dispatch = useDispatch(); const intl = useIntl(); - const account = useSelector(state => state.getIn(['accounts', me])); const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to'])); const handleCancelClick = useCallback(() => { @@ -29,7 +28,7 @@ export const NavigationBar = () => { return (
- + {isReplying ? : }
); diff --git a/app/javascript/mastodon/features/compose/components/poll_form.jsx b/app/javascript/mastodon/features/compose/components/poll_form.jsx index d2adc58cc0..e8d5331196 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.jsx +++ b/app/javascript/mastodon/features/compose/components/poll_form.jsx @@ -25,7 +25,7 @@ const messages = defineMessages({ minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, - singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' }, + singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Single choice' }, multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' }, }); diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx index 6a482c8ec2..986d78e8b1 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.jsx +++ b/app/javascript/mastodon/features/compose/components/search_results.jsx @@ -6,6 +6,7 @@ import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import { expandSearch } from 'mastodon/actions/search'; +import { Account } from 'mastodon/components/account'; import { Icon } from 'mastodon/components/icon'; import { LoadMore } from 'mastodon/components/load_more'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; @@ -13,7 +14,6 @@ import { SearchSection } from 'mastodon/features/explore/components/search_secti import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; -import AccountContainer from '../../../containers/account_container'; import StatusContainer from '../../../containers/status_container'; const INITIAL_PAGE_LIMIT = 10; @@ -49,7 +49,7 @@ export const SearchResults = () => { if (results.get('accounts') && results.get('accounts').size > 0) { accounts = ( }> - {withoutLastResult(results.get('accounts')).map(accountId => )} + {withoutLastResult(results.get('accounts')).map(accountId => )} {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && } ); diff --git a/app/javascript/mastodon/features/compose/components/upload.jsx b/app/javascript/mastodon/features/compose/components/upload.jsx deleted file mode 100644 index 7f6ef6cfd8..0000000000 --- a/app/javascript/mastodon/features/compose/components/upload.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import PropTypes from 'prop-types'; -import { useCallback } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import { useDispatch, useSelector } from 'react-redux'; - -import spring from 'react-motion/lib/spring'; - -import CloseIcon from '@/material-icons/400-20px/close.svg?react'; -import EditIcon from '@/material-icons/400-24px/edit.svg?react'; -import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; -import { undoUploadCompose, initMediaEditModal } from 'mastodon/actions/compose'; -import { Blurhash } from 'mastodon/components/blurhash'; -import { Icon } from 'mastodon/components/icon'; -import Motion from 'mastodon/features/ui/util/optional_motion'; - -export const Upload = ({ id, onDragStart, onDragEnter, onDragEnd }) => { - const dispatch = useDispatch(); - const media = useSelector(state => state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id)); - const sensitive = useSelector(state => state.getIn(['compose', 'spoiler'])); - - const handleUndoClick = useCallback(() => { - dispatch(undoUploadCompose(id)); - }, [dispatch, id]); - - const handleFocalPointClick = useCallback(() => { - dispatch(initMediaEditModal(id)); - }, [dispatch, id]); - - const handleDragStart = useCallback(() => { - onDragStart(id); - }, [onDragStart, id]); - - const handleDragEnter = useCallback(() => { - onDragEnter(id); - }, [onDragEnter, id]); - - if (!media) { - return null; - } - - const focusX = media.getIn(['meta', 'focus', 'x']); - const focusY = media.getIn(['meta', 'focus', 'y']); - const x = ((focusX / 2) + .5) * 100; - const y = ((focusY / -2) + .5) * 100; - const missingDescription = (media.get('description') || '').length === 0; - - return ( -
- - {({ scale }) => ( -
- {sensitive && } - -
- - -
- -
- -
-
- )} -
-
- ); -}; - -Upload.propTypes = { - id: PropTypes.string, - onDragEnter: PropTypes.func, - onDragStart: PropTypes.func, - onDragEnd: PropTypes.func, -}; diff --git a/app/javascript/mastodon/features/compose/components/upload.tsx b/app/javascript/mastodon/features/compose/components/upload.tsx new file mode 100644 index 0000000000..3a24b28291 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload.tsx @@ -0,0 +1,130 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +import CloseIcon from '@/material-icons/400-20px/close.svg?react'; +import EditIcon from '@/material-icons/400-24px/edit.svg?react'; +import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; +import { + undoUploadCompose, + initMediaEditModal, +} from 'mastodon/actions/compose'; +import { Blurhash } from 'mastodon/components/blurhash'; +import { Icon } from 'mastodon/components/icon'; +import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +export const Upload: React.FC<{ + id: string; + dragging?: boolean; + overlay?: boolean; + tall?: boolean; + wide?: boolean; +}> = ({ id, dragging, overlay, tall, wide }) => { + const dispatch = useAppDispatch(); + const media = useAppSelector( + (state) => + state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call + .get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access + .find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access + | MediaAttachment + | undefined, + ); + const sensitive = useAppSelector( + (state) => state.compose.get('spoiler') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + ); + + const handleUndoClick = useCallback(() => { + dispatch(undoUploadCompose(id)); + }, [dispatch, id]); + + const handleFocalPointClick = useCallback(() => { + dispatch(initMediaEditModal(id)); + }, [dispatch, id]); + + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id }); + + if (!media) { + return null; + } + + const focusX = media.getIn(['meta', 'focus', 'x']) as number; + const focusY = media.getIn(['meta', 'focus', 'y']) as number; + const x = (focusX / 2 + 0.5) * 100; + const y = (focusY / -2 + 0.5) * 100; + const missingDescription = + ((media.get('description') as string | undefined) ?? '').length === 0; + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ {sensitive && ( + + )} + +
+ + +
+ +
+ +
+
+
+ ); +}; diff --git a/app/javascript/mastodon/features/compose/components/upload_form.jsx b/app/javascript/mastodon/features/compose/components/upload_form.jsx deleted file mode 100644 index adf5591382..0000000000 --- a/app/javascript/mastodon/features/compose/components/upload_form.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useRef, useCallback } from 'react'; - -import { useSelector, useDispatch } from 'react-redux'; - -import { changeMediaOrder } from 'mastodon/actions/compose'; - -import { Upload } from './upload'; -import { UploadProgress } from './upload_progress'; - -export const UploadForm = () => { - const dispatch = useDispatch(); - const mediaIds = useSelector(state => state.getIn(['compose', 'media_attachments']).map(item => item.get('id'))); - const active = useSelector(state => state.getIn(['compose', 'is_uploading'])); - const progress = useSelector(state => state.getIn(['compose', 'progress'])); - const isProcessing = useSelector(state => state.getIn(['compose', 'is_processing'])); - - const dragItem = useRef(); - const dragOverItem = useRef(); - - const handleDragStart = useCallback(id => { - dragItem.current = id; - }, [dragItem]); - - const handleDragEnter = useCallback(id => { - dragOverItem.current = id; - }, [dragOverItem]); - - const handleDragEnd = useCallback(() => { - dispatch(changeMediaOrder(dragItem.current, dragOverItem.current)); - dragItem.current = null; - dragOverItem.current = null; - }, [dispatch, dragItem, dragOverItem]); - - return ( - <> - - - {mediaIds.size > 0 && ( -
- {mediaIds.map(id => ( - - ))} -
- )} - - ); -}; diff --git a/app/javascript/mastodon/features/compose/components/upload_form.tsx b/app/javascript/mastodon/features/compose/components/upload_form.tsx new file mode 100644 index 0000000000..1c01c2bd9c --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_form.tsx @@ -0,0 +1,185 @@ +import { useState, useCallback, useMemo } from 'react'; + +import { useIntl, defineMessages } from 'react-intl'; + +import type { List } from 'immutable'; + +import type { + DragStartEvent, + DragEndEvent, + UniqueIdentifier, + Announcements, + ScreenReaderInstructions, +} from '@dnd-kit/core'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragOverlay, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, +} from '@dnd-kit/sortable'; + +import { changeMediaOrder } from 'mastodon/actions/compose'; +import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { Upload } from './upload'; +import { UploadProgress } from './upload_progress'; + +const messages = defineMessages({ + screenReaderInstructions: { + id: 'upload_form.drag_and_drop.instructions', + defaultMessage: + 'To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.', + }, + onDragStart: { + id: 'upload_form.drag_and_drop.on_drag_start', + defaultMessage: 'Picked up media attachment {item}.', + }, + onDragOver: { + id: 'upload_form.drag_and_drop.on_drag_over', + defaultMessage: 'Media attachment {item} was moved.', + }, + onDragEnd: { + id: 'upload_form.drag_and_drop.on_drag_end', + defaultMessage: 'Media attachment {item} was dropped.', + }, + onDragCancel: { + id: 'upload_form.drag_and_drop.on_drag_cancel', + defaultMessage: + 'Dragging was cancelled. Media attachment {item} was dropped.', + }, +}); + +export const UploadForm: React.FC = () => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const mediaIds = useAppSelector( + (state) => + state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call + .get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access + .map((item: MediaAttachment) => item.get('id')) as List, // eslint-disable-line @typescript-eslint/no-unsafe-member-access + ); + const active = useAppSelector( + (state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + ); + const progress = useAppSelector( + (state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + ); + const isProcessing = useAppSelector( + (state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + ); + const [activeId, setActiveId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragStart = useCallback( + (e: DragStartEvent) => { + const { active } = e; + + setActiveId(active.id); + }, + [setActiveId], + ); + + const handleDragEnd = useCallback( + (e: DragEndEvent) => { + const { active, over } = e; + + if (over && active.id !== over.id) { + dispatch(changeMediaOrder(active.id, over.id)); + } + + setActiveId(null); + }, + [dispatch, setActiveId], + ); + + const accessibility: { + screenReaderInstructions: ScreenReaderInstructions; + announcements: Announcements; + } = useMemo( + () => ({ + screenReaderInstructions: { + draggable: intl.formatMessage(messages.screenReaderInstructions), + }, + + announcements: { + onDragStart({ active }) { + return intl.formatMessage(messages.onDragStart, { item: active.id }); + }, + + onDragOver({ active }) { + return intl.formatMessage(messages.onDragOver, { item: active.id }); + }, + + onDragEnd({ active }) { + return intl.formatMessage(messages.onDragEnd, { item: active.id }); + }, + + onDragCancel({ active }) { + return intl.formatMessage(messages.onDragCancel, { item: active.id }); + }, + }, + }), + [intl], + ); + + return ( + <> + + + {mediaIds.size > 0 && ( +
+ + + {mediaIds.map((id, idx) => ( + + ))} + + + + {activeId ? : null} + + +
+ )} + + ); +}; diff --git a/app/javascript/mastodon/features/directory/index.tsx b/app/javascript/mastodon/features/directory/index.tsx index d0e57600bb..ef2649a27f 100644 --- a/app/javascript/mastodon/features/directory/index.tsx +++ b/app/javascript/mastodon/features/directory/index.tsx @@ -15,7 +15,8 @@ import { changeColumnParams, } from 'mastodon/actions/columns'; import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory'; -import Column from 'mastodon/components/column'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { LoadMore } from 'mastodon/components/load_more'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; @@ -49,7 +50,7 @@ export const Directory: React.FC<{ const intl = useIntl(); const dispatch = useAppDispatch(); - const column = useRef(null); + const column = useRef(null); const [orderParam, setOrderParam] = useSearchParam('order'); const [localParam, setLocalParam] = useSearchParam('local'); diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx index 035e5aaad8..68cf0283ea 100644 --- a/app/javascript/mastodon/features/explore/links.jsx +++ b/app/javascript/mastodon/features/explore/links.jsx @@ -45,7 +45,7 @@ class Links extends PureComponent { const banner = ( - + ); diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx index 355c0f1c4c..ad70d459d8 100644 --- a/app/javascript/mastodon/features/explore/results.jsx +++ b/app/javascript/mastodon/features/explore/results.jsx @@ -13,10 +13,10 @@ import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import { submitSearch, expandSearch } from 'mastodon/actions/search'; +import { Account } from 'mastodon/components/account'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import { Icon } from 'mastodon/components/icon'; import ScrollableList from 'mastodon/components/scrollable_list'; -import Account from 'mastodon/containers/account_container'; import Status from 'mastodon/containers/status_container'; import { SearchSection } from './components/search_section'; diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx index 414b47fcdd..883f6906b6 100644 --- a/app/javascript/mastodon/features/explore/statuses.jsx +++ b/app/javascript/mastodon/features/explore/statuses.jsx @@ -58,7 +58,7 @@ class Statuses extends PureComponent { return ( } + prepend={} alwaysPrepend timelineId='explore' statusIds={statusIds} diff --git a/app/javascript/mastodon/features/explore/suggestions.jsx b/app/javascript/mastodon/features/explore/suggestions.jsx index 101ec0d195..b469a15252 100644 --- a/app/javascript/mastodon/features/explore/suggestions.jsx +++ b/app/javascript/mastodon/features/explore/suggestions.jsx @@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl'; import { withRouter } from 'react-router-dom'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { fetchSuggestions } from 'mastodon/actions/suggestions'; @@ -15,15 +14,15 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { Card } from './components/card'; const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), + suggestions: state.suggestions.items, + isLoading: state.suggestions.isLoading, }); class Suggestions extends PureComponent { static propTypes = { isLoading: PropTypes.bool, - suggestions: ImmutablePropTypes.list, + suggestions: PropTypes.array, dispatch: PropTypes.func.isRequired, ...WithRouterPropTypes, }; @@ -32,17 +31,17 @@ class Suggestions extends PureComponent { const { dispatch, suggestions, history } = this.props; // If we're navigating back to the screen, do not trigger a reload - if (history.action === 'POP' && suggestions.size > 0) { + if (history.action === 'POP' && suggestions.length > 0) { return; } - dispatch(fetchSuggestions(true)); + dispatch(fetchSuggestions()); } render () { const { isLoading, suggestions } = this.props; - if (!isLoading && suggestions.isEmpty()) { + if (!isLoading && suggestions.length === 0) { return (
@@ -56,9 +55,9 @@ class Suggestions extends PureComponent {
{isLoading ? : suggestions.map(suggestion => ( ))}
diff --git a/app/javascript/mastodon/features/explore/tags.jsx b/app/javascript/mastodon/features/explore/tags.jsx index 90cf3c32a7..b803d5fa1b 100644 --- a/app/javascript/mastodon/features/explore/tags.jsx +++ b/app/javascript/mastodon/features/explore/tags.jsx @@ -44,7 +44,7 @@ class Tags extends PureComponent { const banner = ( - + ); diff --git a/app/javascript/mastodon/features/favourites/index.jsx b/app/javascript/mastodon/features/favourites/index.jsx index 27ca169409..c5c6abe081 100644 --- a/app/javascript/mastodon/features/favourites/index.jsx +++ b/app/javascript/mastodon/features/favourites/index.jsx @@ -12,11 +12,11 @@ import { debounce } from 'lodash'; import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react'; import { fetchFavourites, expandFavourites } from 'mastodon/actions/interactions'; +import { Account } from 'mastodon/components/account'; import ColumnHeader from 'mastodon/components/column_header'; import { Icon } from 'mastodon/components/icon'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import ScrollableList from 'mastodon/components/scrollable_list'; -import AccountContainer from 'mastodon/containers/account_container'; import Column from 'mastodon/features/ui/components/column'; const messages = defineMessages({ @@ -87,7 +87,7 @@ class Favourites extends ImmutablePureComponent { bindToDocument={!multiColumn} > {accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/firehose/index.jsx b/app/javascript/mastodon/features/firehose/index.jsx index f65bee45ec..ff80aac220 100644 --- a/app/javascript/mastodon/features/firehose/index.jsx +++ b/app/javascript/mastodon/features/firehose/index.jsx @@ -133,7 +133,7 @@ const Firehose = ({ feedType, multiColumn }) => { diff --git a/app/javascript/mastodon/features/follow_requests/index.jsx b/app/javascript/mastodon/features/follow_requests/index.jsx index a8f40a31d0..7d651f2ca6 100644 --- a/app/javascript/mastodon/features/follow_requests/index.jsx +++ b/app/javascript/mastodon/features/follow_requests/index.jsx @@ -68,7 +68,7 @@ class FollowRequests extends ImmutablePureComponent { ); return ( - + {forceEmptyState ? [] : accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx index b53a016ff1..d37c0c30ef 100644 --- a/app/javascript/mastodon/features/following/index.jsx +++ b/app/javascript/mastodon/features/following/index.jsx @@ -8,6 +8,7 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; +import { Account } from 'mastodon/components/account'; import { TimelineHint } from 'mastodon/components/timeline_hint'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; @@ -23,7 +24,6 @@ import { import { ColumnBackButton } from '../../components/column_back_button'; import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; import HeaderContainer from '../account_timeline/containers/header_container'; import Column from '../ui/components/column'; @@ -175,7 +175,7 @@ class Following extends ImmutablePureComponent { bindToDocument={!multiColumn} > {forceEmptyState ? [] : accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx deleted file mode 100644 index d2fe8851f6..0000000000 --- a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import { Button } from 'mastodon/components/button'; -import { ShortNumber } from 'mastodon/components/short_number'; - -const messages = defineMessages({ - followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, - unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, -}); - -const usesRenderer = (displayNumber, pluralReady) => ( - {displayNumber}
, - }} - /> -); - -const peopleRenderer = (displayNumber, pluralReady) => ( - {displayNumber}
, - }} - /> -); - -const usesTodayRenderer = (displayNumber, pluralReady) => ( - {displayNumber}, - }} - /> -); - -export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => { - if (!tag) { - return null; - } - - const [uses, people] = tag.get('history').reduce((arr, day) => [arr[0] + day.get('uses') * 1, arr[1] + day.get('accounts') * 1], [0, 0]); - const dividingCircle = {' · '}; - - return ( -
-
-

#{tag.get('name')}

-
- -
- - {dividingCircle} - - {dividingCircle} - -
-
- ); -}); - -HashtagHeader.propTypes = { - tag: ImmutablePropTypes.map, - disabled: PropTypes.bool, - onClick: PropTypes.func, - intl: PropTypes.object, -}; diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx new file mode 100644 index 0000000000..7372fe5284 --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx @@ -0,0 +1,188 @@ +import { useCallback, useMemo, useState, useEffect } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import { + fetchHashtag, + followHashtag, + unfollowHashtag, +} from 'mastodon/actions/tags_typed'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; +import { Button } from 'mastodon/components/button'; +import { ShortNumber } from 'mastodon/components/short_number'; +import DropdownMenu from 'mastodon/containers/dropdown_menu_container'; +import { useIdentity } from 'mastodon/identity_context'; +import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions'; +import { useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { + id: 'hashtag.unfollow', + defaultMessage: 'Unfollow hashtag', + }, + adminModeration: { + id: 'hashtag.admin_moderation', + defaultMessage: 'Open moderation interface for #{name}', + }, +}); + +const usesRenderer = (displayNumber: React.ReactNode, pluralReady: number) => ( + {displayNumber}, + }} + /> +); + +const peopleRenderer = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + {displayNumber}, + }} + /> +); + +const usesTodayRenderer = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + {displayNumber}, + }} + /> +); + +export const HashtagHeader: React.FC<{ + tagId: string; +}> = ({ tagId }) => { + const intl = useIntl(); + const { signedIn, permissions } = useIdentity(); + const dispatch = useAppDispatch(); + const [tag, setTag] = useState(); + + useEffect(() => { + void dispatch(fetchHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + }, [dispatch, tagId, setTag]); + + const menu = useMemo(() => { + const tmp = []; + + if ( + tag && + signedIn && + (permissions & PERMISSION_MANAGE_TAXONOMIES) === + PERMISSION_MANAGE_TAXONOMIES + ) { + tmp.push({ + text: intl.formatMessage(messages.adminModeration, { name: tag.id }), + href: `/admin/tags/${tag.id}`, + }); + } + + return tmp; + }, [signedIn, permissions, intl, tag]); + + const handleFollow = useCallback(() => { + if (!signedIn || !tag) { + return; + } + + if (tag.following) { + setTag((hashtag) => hashtag && { ...hashtag, following: false }); + + void dispatch(unfollowHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } else { + setTag((hashtag) => hashtag && { ...hashtag, following: true }); + + void dispatch(followHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } + }, [dispatch, setTag, signedIn, tag, tagId]); + + if (!tag) { + return null; + } + + const [uses, people] = tag.history.reduce( + (arr, day) => [ + arr[0] + parseInt(day.uses), + arr[1] + parseInt(day.accounts), + ], + [0, 0], + ); + const dividingCircle = {' · '}; + + return ( +
+
+

#{tag.name}

+ +
+ {menu.length > 0 && ( + + )} + +
+
+ +
+ + {dividingCircle} + + {dividingCircle} + +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.jsx b/app/javascript/mastodon/features/hashtag_timeline/index.jsx index 42a668859e..ab3c32e6a7 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.jsx +++ b/app/javascript/mastodon/features/hashtag_timeline/index.jsx @@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl'; import { Helmet } from 'react-helmet'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { isEqual } from 'lodash'; @@ -13,7 +12,6 @@ import { isEqual } from 'lodash'; import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { connectHashtagStream } from 'mastodon/actions/streaming'; -import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; @@ -26,7 +24,6 @@ import ColumnSettingsContainer from './containers/column_settings_container'; const mapStateToProps = (state, props) => ({ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0, - tag: state.getIn(['tags', props.params.id]), }); class HashtagTimeline extends PureComponent { @@ -38,7 +35,6 @@ class HashtagTimeline extends PureComponent { columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, hasUnread: PropTypes.bool, - tag: ImmutablePropTypes.map, multiColumn: PropTypes.bool, }; @@ -130,7 +126,6 @@ class HashtagTimeline extends PureComponent { this._subscribe(dispatch, id, tags, local); dispatch(expandHashtagTimeline(id, { tags, local })); - dispatch(fetchHashtag(id)); } componentDidMount () { @@ -162,27 +157,10 @@ class HashtagTimeline extends PureComponent { dispatch(expandHashtagTimeline(id, { maxId, tags, local })); }; - handleFollow = () => { - const { dispatch, params, tag } = this.props; - const { id } = params; - const { signedIn } = this.props.identity; - - if (!signedIn) { - return; - } - - if (tag.get('following')) { - dispatch(unfollowHashtag(id)); - } else { - dispatch(followHashtag(id)); - } - }; - render () { - const { hasUnread, columnId, multiColumn, tag } = this.props; + const { hasUnread, columnId, multiColumn } = this.props; const { id, local } = this.props.params; const pinned = !!columnId; - const { signedIn } = this.props.identity; return ( @@ -202,7 +180,7 @@ class HashtagTimeline extends PureComponent { } + prepend={pinned ? null : } alwaysPrepend trackScroll={!pinned} scrollKey={`hashtag_timeline-${columnId}`} diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx deleted file mode 100644 index 1b8040e55b..0000000000 --- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx +++ /dev/null @@ -1,207 +0,0 @@ -import PropTypes from 'prop-types'; -import { useEffect, useCallback, useRef, useState } from 'react'; - -import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { useDispatch, useSelector } from 'react-redux'; - -import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; -import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import InfoIcon from '@/material-icons/400-24px/info.svg?react'; -import { changeSetting } from 'mastodon/actions/settings'; -import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions'; -import { Avatar } from 'mastodon/components/avatar'; -import { DisplayName } from 'mastodon/components/display_name'; -import { FollowButton } from 'mastodon/components/follow_button'; -import { Icon } from 'mastodon/components/icon'; -import { IconButton } from 'mastodon/components/icon_button'; -import { VerifiedBadge } from 'mastodon/components/verified_badge'; -import { domain } from 'mastodon/initial_state'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, - next: { id: 'lightbox.next', defaultMessage: 'Next' }, - dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" }, - friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' }, - similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' }, - featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' }, - mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'}, - mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' }, -}); - -const Source = ({ id }) => { - const intl = useIntl(); - - let label, hint; - - switch (id) { - case 'friends_of_friends': - hint = intl.formatMessage(messages.friendsOfFriendsHint); - label = ; - break; - case 'similar_to_recently_followed': - hint = intl.formatMessage(messages.similarToRecentlyFollowedHint); - label = ; - break; - case 'featured': - hint = intl.formatMessage(messages.featuredHint, { domain }); - label = ; - break; - case 'most_followed': - hint = intl.formatMessage(messages.mostFollowedHint, { domain }); - label = ; - break; - case 'most_interactions': - hint = intl.formatMessage(messages.mostInteractionsHint, { domain }); - label = ; - break; - } - - return ( -
- - {label} -
- ); -}; - -Source.propTypes = { - id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']), -}; - -const Card = ({ id, sources }) => { - const intl = useIntl(); - const account = useSelector(state => state.getIn(['accounts', id])); - const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at')); - const dispatch = useDispatch(); - - const handleDismiss = useCallback(() => { - dispatch(dismissSuggestion(id)); - }, [id, dispatch]); - - return ( -
- - -
- -
- -
- - {firstVerifiedField ? : } -
- - -
- ); -}; - -Card.propTypes = { - id: PropTypes.string.isRequired, - sources: ImmutablePropTypes.list, -}; - -const DISMISSIBLE_ID = 'home/follow-suggestions'; - -export const InlineFollowSuggestions = ({ hidden }) => { - const intl = useIntl(); - const dispatch = useDispatch(); - const suggestions = useSelector(state => state.getIn(['suggestions', 'items'])); - const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading'])); - const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID])); - const bodyRef = useRef(); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(true); - - useEffect(() => { - dispatch(fetchSuggestions()); - }, [dispatch]); - - useEffect(() => { - if (!bodyRef.current) { - return; - } - - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth); - }, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]); - - const handleLeftNav = useCallback(() => { - bodyRef.current.scrollLeft -= 200; - }, [bodyRef]); - - const handleRightNav = useCallback(() => { - bodyRef.current.scrollLeft += 200; - }, [bodyRef]); - - const handleScroll = useCallback(() => { - if (!bodyRef.current) { - return; - } - - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth); - }, [setCanScrollRight, setCanScrollLeft, bodyRef]); - - const handleDismiss = useCallback(() => { - dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); - }, [dispatch]); - - if (dismissed || (!isLoading && suggestions.isEmpty())) { - return null; - } - - if (hidden) { - return ( -
- ); - } - - return ( -
-
-

- -
- - -
-
- -
-
- {suggestions.map(suggestion => ( - - ))} -
- - {canScrollLeft && ( - - )} - - {canScrollRight && ( - - )} -
-
- ); -}; - -InlineFollowSuggestions.propTypes = { - hidden: PropTypes.bool, -}; diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx new file mode 100644 index 0000000000..5fd47443d9 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx @@ -0,0 +1,326 @@ +import { useEffect, useCallback, useRef, useState } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import { changeSetting } from 'mastodon/actions/settings'; +import { + fetchSuggestions, + dismissSuggestion, +} from 'mastodon/actions/suggestions'; +import type { ApiSuggestionSourceJSON } from 'mastodon/api_types/suggestions'; +import { Avatar } from 'mastodon/components/avatar'; +import { DisplayName } from 'mastodon/components/display_name'; +import { FollowButton } from 'mastodon/components/follow_button'; +import { Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; +import { VerifiedBadge } from 'mastodon/components/verified_badge'; +import { domain } from 'mastodon/initial_state'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, + dismiss: { + id: 'follow_suggestions.dismiss', + defaultMessage: "Don't show again", + }, + friendsOfFriendsHint: { + id: 'follow_suggestions.hints.friends_of_friends', + defaultMessage: 'This profile is popular among the people you follow.', + }, + similarToRecentlyFollowedHint: { + id: 'follow_suggestions.hints.similar_to_recently_followed', + defaultMessage: + 'This profile is similar to the profiles you have most recently followed.', + }, + featuredHint: { + id: 'follow_suggestions.hints.featured', + defaultMessage: 'This profile has been hand-picked by the {domain} team.', + }, + mostFollowedHint: { + id: 'follow_suggestions.hints.most_followed', + defaultMessage: 'This profile is one of the most followed on {domain}.', + }, + mostInteractionsHint: { + id: 'follow_suggestions.hints.most_interactions', + defaultMessage: + 'This profile has been recently getting a lot of attention on {domain}.', + }, +}); + +const Source: React.FC<{ + id: ApiSuggestionSourceJSON; +}> = ({ id }) => { + const intl = useIntl(); + + let label, hint; + + switch (id) { + case 'friends_of_friends': + hint = intl.formatMessage(messages.friendsOfFriendsHint); + label = ( + + ); + break; + case 'similar_to_recently_followed': + hint = intl.formatMessage(messages.similarToRecentlyFollowedHint); + label = ( + + ); + break; + case 'featured': + hint = intl.formatMessage(messages.featuredHint, { domain }); + label = ( + + ); + break; + case 'most_followed': + hint = intl.formatMessage(messages.mostFollowedHint, { domain }); + label = ( + + ); + break; + case 'most_interactions': + hint = intl.formatMessage(messages.mostInteractionsHint, { domain }); + label = ( + + ); + break; + } + + return ( +
+ + {label} +
+ ); +}; + +const Card: React.FC<{ + id: string; + sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]]; +}> = ({ id, sources }) => { + const intl = useIntl(); + const account = useAppSelector((state) => state.accounts.get(id)); + const firstVerifiedField = account?.fields.find((item) => !!item.verified_at); + const dispatch = useAppDispatch(); + + const handleDismiss = useCallback(() => { + void dispatch(dismissSuggestion({ accountId: id })); + }, [id, dispatch]); + + return ( +
+ + +
+ + + +
+ +
+ + + + {firstVerifiedField ? ( + + ) : ( + + )} +
+ + +
+ ); +}; + +const DISMISSIBLE_ID = 'home/follow-suggestions'; + +export const InlineFollowSuggestions: React.FC<{ + hidden?: boolean; +}> = ({ hidden }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const suggestions = useAppSelector((state) => state.suggestions.items); + const isLoading = useAppSelector((state) => state.suggestions.isLoading); + const dismissed = useAppSelector( + (state) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean, + ); + const bodyRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + useEffect(() => { + void dispatch(fetchSuggestions()); + }, [dispatch]); + + useEffect(() => { + if (!bodyRef.current) { + return; + } + + if (getComputedStyle(bodyRef.current).direction === 'rtl') { + setCanScrollLeft( + bodyRef.current.clientWidth - bodyRef.current.scrollLeft < + bodyRef.current.scrollWidth, + ); + setCanScrollRight(bodyRef.current.scrollLeft < 0); + } else { + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight( + bodyRef.current.scrollLeft + bodyRef.current.clientWidth < + bodyRef.current.scrollWidth, + ); + } + }, [setCanScrollRight, setCanScrollLeft, suggestions]); + + const handleLeftNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft -= 200; + }, []); + + const handleRightNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft += 200; + }, []); + + const handleScroll = useCallback(() => { + if (!bodyRef.current) { + return; + } + + if (getComputedStyle(bodyRef.current).direction === 'rtl') { + setCanScrollLeft( + bodyRef.current.clientWidth - bodyRef.current.scrollLeft < + bodyRef.current.scrollWidth, + ); + setCanScrollRight(bodyRef.current.scrollLeft < 0); + } else { + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight( + bodyRef.current.scrollLeft + bodyRef.current.clientWidth < + bodyRef.current.scrollWidth, + ); + } + }, [setCanScrollRight, setCanScrollLeft]); + + const handleDismiss = useCallback(() => { + dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); + }, [dispatch]); + + if (dismissed || (!isLoading && suggestions.length === 0)) { + return null; + } + + if (hidden) { + return
; + } + + return ( +
+
+

+ +

+ +
+ + + + +
+
+ +
+
+ {suggestions.map((suggestion) => ( + + ))} +
+ + {canScrollLeft && ( + + )} + + {canScrollRight && ( + + )} +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx index 723e27ae1c..446cc2586a 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ b/app/javascript/mastodon/features/interaction_modal/index.jsx @@ -9,6 +9,7 @@ import { connect } from 'react-redux'; import { throttle, escapeRegExp } from 'lodash'; +import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; @@ -340,7 +341,7 @@ class InteractionModal extends React.PureComponent { static propTypes = { displayNameHtml: PropTypes.string, url: PropTypes.string, - type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']), + type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow', 'vote']), onSignupClick: PropTypes.func.isRequired, signupUrl: PropTypes.string.isRequired, }; @@ -377,6 +378,11 @@ class InteractionModal extends React.PureComponent { title = ; actionDescription = ; break; + case 'vote': + icon = ; + title = ; + actionDescription = ; + break; } let signupButton; diff --git a/app/javascript/mastodon/features/link_timeline/index.tsx b/app/javascript/mastodon/features/link_timeline/index.tsx index 262a21afcc..1b3f287177 100644 --- a/app/javascript/mastodon/features/link_timeline/index.tsx +++ b/app/javascript/mastodon/features/link_timeline/index.tsx @@ -5,7 +5,8 @@ import { useParams } from 'react-router-dom'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; import { expandLinkTimeline } from 'mastodon/actions/timelines'; -import Column from 'mastodon/components/column'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import StatusListContainer from 'mastodon/features/ui/containers/status_list_container'; import type { Card } from 'mastodon/models/status'; @@ -17,7 +18,7 @@ export const LinkTimeline: React.FC<{ const { url } = useParams<{ url: string }>(); const decodedUrl = url ? decodeURIComponent(url) : undefined; const dispatch = useAppDispatch(); - const columnRef = useRef(null); + const columnRef = useRef(null); const firstStatusId = useAppSelector((state) => decodedUrl ? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access diff --git a/app/javascript/mastodon/features/list_adder/components/account.jsx b/app/javascript/mastodon/features/list_adder/components/account.jsx deleted file mode 100644 index 94a90726e3..0000000000 --- a/app/javascript/mastodon/features/list_adder/components/account.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import { injectIntl } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { Avatar } from '../../../components/avatar'; -import { DisplayName } from '../../../components/display_name'; -import { makeGetAccount } from '../../../selectors'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, { accountId }) => ({ - account: getAccount(state, accountId), - }); - - return mapStateToProps; -}; - -class Account extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record.isRequired, - }; - - render () { - const { account } = this.props; - return ( -
-
-
-
- -
-
-
- ); - } - -} - -export default connect(makeMapStateToProps)(injectIntl(Account)); diff --git a/app/javascript/mastodon/features/list_adder/components/list.jsx b/app/javascript/mastodon/features/list_adder/components/list.jsx deleted file mode 100644 index a7cfd60bf3..0000000000 --- a/app/javascript/mastodon/features/list_adder/components/list.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import AddIcon from '@/material-icons/400-24px/add.svg?react'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -import { removeFromListAdder, addToListAdder } from '../../../actions/lists'; -import { IconButton } from '../../../components/icon_button'; - -const messages = defineMessages({ - remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, - add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, -}); - -const MapStateToProps = (state, { listId, added }) => ({ - list: state.get('lists').get(listId), - added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added, -}); - -const mapDispatchToProps = (dispatch, { listId }) => ({ - onRemove: () => dispatch(removeFromListAdder(listId)), - onAdd: () => dispatch(addToListAdder(listId)), -}); - -class List extends ImmutablePureComponent { - - static propTypes = { - list: ImmutablePropTypes.map.isRequired, - intl: PropTypes.object.isRequired, - onRemove: PropTypes.func.isRequired, - onAdd: PropTypes.func.isRequired, - added: PropTypes.bool, - }; - - static defaultProps = { - added: false, - }; - - render () { - const { list, intl, onRemove, onAdd, added } = this.props; - - let button; - - if (added) { - button = ; - } else { - button = ; - } - - return ( -
-
-
- - {list.get('title')} -
- -
- {button} -
-
-
- ); - } - -} - -export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List)); diff --git a/app/javascript/mastodon/features/list_adder/index.jsx b/app/javascript/mastodon/features/list_adder/index.jsx deleted file mode 100644 index 4e7bd46bdf..0000000000 --- a/app/javascript/mastodon/features/list_adder/index.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; - -import { injectIntl } from 'react-intl'; - -import { createSelector } from '@reduxjs/toolkit'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { setupListAdder, resetListAdder } from '../../actions/lists'; -import NewListForm from '../lists/components/new_list_form'; - -import Account from './components/account'; -import List from './components/list'; -// hack - -const getOrderedLists = createSelector([state => state.get('lists')], lists => { - if (!lists) { - return lists; - } - - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); -}); - -const mapStateToProps = state => ({ - listIds: getOrderedLists(state).map(list=>list.get('id')), -}); - -const mapDispatchToProps = dispatch => ({ - onInitialize: accountId => dispatch(setupListAdder(accountId)), - onReset: () => dispatch(resetListAdder()), -}); - -class ListAdder extends ImmutablePureComponent { - - static propTypes = { - accountId: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - onInitialize: PropTypes.func.isRequired, - onReset: PropTypes.func.isRequired, - listIds: ImmutablePropTypes.list.isRequired, - }; - - componentDidMount () { - const { onInitialize, accountId } = this.props; - onInitialize(accountId); - } - - componentWillUnmount () { - const { onReset } = this.props; - onReset(); - } - - render () { - const { accountId, listIds } = this.props; - - return ( -
-
- -
- - - - -
- {listIds.map(ListId => )} -
-
- ); - } - -} - -export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder)); diff --git a/app/javascript/mastodon/features/list_adder/index.tsx b/app/javascript/mastodon/features/list_adder/index.tsx new file mode 100644 index 0000000000..5429c24aed --- /dev/null +++ b/app/javascript/mastodon/features/list_adder/index.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState, useCallback } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import { fetchLists } from 'mastodon/actions/lists'; +import { createList } from 'mastodon/actions/lists_typed'; +import { + apiGetAccountLists, + apiAddAccountToList, + apiRemoveAccountFromList, +} from 'mastodon/api/lists'; +import type { ApiListJSON } from 'mastodon/api_types/lists'; +import { Button } from 'mastodon/components/button'; +import { CheckBox } from 'mastodon/components/check_box'; +import { Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; +import { getOrderedLists } from 'mastodon/selectors/lists'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + newList: { + id: 'lists.new_list_name', + defaultMessage: 'New list name', + }, + createList: { + id: 'lists.create', + defaultMessage: 'Create', + }, + close: { + id: 'lightbox.close', + defaultMessage: 'Close', + }, +}); + +const ListItem: React.FC<{ + id: string; + title: string; + checked: boolean; + onChange: (id: string, checked: boolean) => void; +}> = ({ id, title, checked, onChange }) => { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(id, e.target.checked); + }, + [id, onChange], + ); + + return ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control + + ); +}; + +const NewListItem: React.FC<{ + onCreate: (list: ApiListJSON) => void; +}> = ({ onCreate }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const [title, setTitle] = useState(''); + + const handleChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setTitle(value); + }, + [setTitle], + ); + + const handleSubmit = useCallback(() => { + if (title.trim().length === 0) { + return; + } + + void dispatch(createList({ title })).then((result) => { + if (isFulfilled(result)) { + onCreate(result.payload); + setTitle(''); + } + + return ''; + }); + }, [setTitle, dispatch, onCreate, title]); + + return ( +
+ + +
+ - -
-
- - -
-
- - {replies_policy !== undefined && ( -
-

- -
- { ['none', 'list', 'followed'].map(policy => ( - - ))} -
-
- )}
@@ -229,4 +178,4 @@ class ListTimeline extends PureComponent { } -export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline))); +export default withRouter(connect(mapStateToProps)(ListTimeline)); diff --git a/app/javascript/mastodon/features/lists/components/new_list_form.jsx b/app/javascript/mastodon/features/lists/components/new_list_form.jsx deleted file mode 100644 index 0fed9d70a2..0000000000 --- a/app/javascript/mastodon/features/lists/components/new_list_form.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { changeListEditorTitle, submitListEditor } from 'mastodon/actions/lists'; -import { Button } from 'mastodon/components/button'; - -const messages = defineMessages({ - label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, - title: { id: 'lists.new.create', defaultMessage: 'Add list' }, -}); - -const mapStateToProps = state => ({ - value: state.getIn(['listEditor', 'title']), - disabled: state.getIn(['listEditor', 'isSubmitting']), -}); - -const mapDispatchToProps = dispatch => ({ - onChange: value => dispatch(changeListEditorTitle(value)), - onSubmit: () => dispatch(submitListEditor(true)), -}); - -class NewListForm extends PureComponent { - - static propTypes = { - value: PropTypes.string.isRequired, - disabled: PropTypes.bool, - intl: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - }; - - handleChange = e => { - this.props.onChange(e.target.value); - }; - - handleSubmit = e => { - e.preventDefault(); - this.props.onSubmit(); - }; - - handleClick = () => { - this.props.onSubmit(); - }; - - render () { - const { value, disabled, intl } = this.props; - - const label = intl.formatMessage(messages.label); - const title = intl.formatMessage(messages.title); - - return ( -
- - -
+
+
+ ); +}; + +const ListMembers: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const dispatch = useAppDispatch(); + const { id } = useParams<{ id: string }>(); + const intl = useIntl(); + + const [searching, setSearching] = useState(false); + const [accountIds, setAccountIds] = useState([]); + const [searchAccountIds, setSearchAccountIds] = useState([]); + const [loading, setLoading] = useState(true); + const [mode, setMode] = useState('remove'); + + useEffect(() => { + if (id) { + setLoading(true); + dispatch(fetchList(id)); + + void apiGetAccounts(id) + .then((data) => { + dispatch(importFetchedAccounts(data)); + setAccountIds(data.map((a) => a.id)); + setLoading(false); + return ''; + }) + .catch(() => { + setLoading(false); + }); + } + }, [dispatch, id]); + + const handleSearchClick = useCallback(() => { + setMode('add'); + }, [setMode]); + + const handleDismissSearchClick = useCallback(() => { + setMode('remove'); + setSearching(false); + }, [setMode]); + + const handleAccountToggle = useCallback( + (accountId: string) => { + const partOfList = accountIds.includes(accountId); + + if (partOfList) { + setAccountIds(accountIds.filter((id) => id !== accountId)); + } else { + setAccountIds([accountId, ...accountIds]); + } + }, + [accountIds, setAccountIds], + ); + + const searchRequestRef = useRef(null); + + const handleSearch = useDebouncedCallback( + (value: string) => { + if (searchRequestRef.current) { + searchRequestRef.current.abort(); + } + + if (value.trim().length === 0) { + setSearching(false); + return; + } + + setLoading(true); + + searchRequestRef.current = new AbortController(); + + void apiRequest('GET', 'v1/accounts/search', { + signal: searchRequestRef.current.signal, + params: { + q: value, + resolve: true, + }, + }) + .then((data) => { + dispatch(importFetchedAccounts(data)); + setSearchAccountIds(data.map((a) => a.id)); + setLoading(false); + setSearching(true); + return ''; + }) + .catch(() => { + setSearching(true); + setLoading(false); + }); + }, + 500, + { leading: true, trailing: true }, + ); + + let displayedAccountIds: string[]; + + if (mode === 'add' && searching) { + displayedAccountIds = searchAccountIds; + } else { + displayedAccountIds = accountIds; + } + + return ( + + + + + + + {displayedAccountIds.length > 0 &&
} + +
+ + + +
+ + } + emptyMessage={ + mode === 'remove' ? ( + <> + + +
+ +
+ + + + ) : ( + + ) + } + > + {displayedAccountIds.map((accountId) => ( + + ))} + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default ListMembers; diff --git a/app/javascript/mastodon/features/lists/new.tsx b/app/javascript/mastodon/features/lists/new.tsx new file mode 100644 index 0000000000..100f126c37 --- /dev/null +++ b/app/javascript/mastodon/features/lists/new.tsx @@ -0,0 +1,296 @@ +import { useCallback, useState, useEffect } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { useParams, useHistory, Link } from 'react-router-dom'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import Toggle from 'react-toggle'; + +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import { fetchList } from 'mastodon/actions/lists'; +import { createList, updateList } from 'mastodon/actions/lists_typed'; +import { apiGetAccounts } from 'mastodon/api/lists'; +import type { RepliesPolicyType } from 'mastodon/api_types/lists'; +import { Column } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + edit: { id: 'column.edit_list', defaultMessage: 'Edit list' }, + create: { id: 'column.create_list', defaultMessage: 'Create list' }, +}); + +const MembersLink: React.FC<{ + id: string; +}> = ({ id }) => { + const [count, setCount] = useState(0); + const [avatars, setAvatars] = useState([]); + + useEffect(() => { + void apiGetAccounts(id) + .then((data) => { + setCount(data.length); + setAvatars(data.slice(0, 3).map((a) => a.avatar)); + return ''; + }) + .catch(() => { + // Nothing + }); + }, [id, setCount, setAvatars]); + + return ( + +
+ + + + +
+ +
+ {avatars.map((url) => ( + + ))} +
+ + ); +}; + +const NewList: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const dispatch = useAppDispatch(); + const { id } = useParams<{ id?: string }>(); + const intl = useIntl(); + const history = useHistory(); + + const list = useAppSelector((state) => + id ? state.lists.get(id) : undefined, + ); + const [title, setTitle] = useState(''); + const [exclusive, setExclusive] = useState(false); + const [repliesPolicy, setRepliesPolicy] = useState('list'); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (id) { + dispatch(fetchList(id)); + } + }, [dispatch, id]); + + useEffect(() => { + if (id && list) { + setTitle(list.title); + setExclusive(list.exclusive); + setRepliesPolicy(list.replies_policy); + } + }, [setTitle, setExclusive, setRepliesPolicy, id, list]); + + const handleTitleChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setTitle(value); + }, + [setTitle], + ); + + const handleExclusiveChange = useCallback( + ({ target: { checked } }: React.ChangeEvent) => { + setExclusive(checked); + }, + [setExclusive], + ); + + const handleRepliesPolicyChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setRepliesPolicy(value as RepliesPolicyType); + }, + [setRepliesPolicy], + ); + + const handleSubmit = useCallback(() => { + setSubmitting(true); + + if (id) { + void dispatch( + updateList({ + id, + title, + exclusive, + replies_policy: repliesPolicy, + }), + ).then(() => { + setSubmitting(false); + return ''; + }); + } else { + void dispatch( + createList({ + title, + exclusive, + replies_policy: repliesPolicy, + }), + ).then((result) => { + setSubmitting(false); + + if (isFulfilled(result)) { + history.replace(`/lists/${result.payload.id}/edit`); + history.push(`/lists/${result.payload.id}/members`); + } + + return ''; + }); + } + }, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]); + + return ( + + + +
+
+
+
+
+ + +
+ +
+
+
+
+ +
+
+
+ + +
+ +
+
+
+
+ + {id && ( +
+ +
+ )} + +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ +
+ +
+
+
+ + + + {intl.formatMessage(id ? messages.edit : messages.create)} + + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default NewList; diff --git a/app/javascript/mastodon/features/mutes/index.jsx b/app/javascript/mastodon/features/mutes/index.jsx index 3b50244ea4..5a5711da58 100644 --- a/app/javascript/mastodon/features/mutes/index.jsx +++ b/app/javascript/mastodon/features/mutes/index.jsx @@ -11,11 +11,11 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react'; +import { Account } from 'mastodon/components/account'; import { fetchMutes, expandMutes } from '../../actions/mutes'; import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; const messages = defineMessages({ @@ -72,7 +72,7 @@ class Mutes extends ImmutablePureComponent { bindToDocument={!multiColumn} > {accountIds.map(id => - , + , )} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.jsx b/app/javascript/mastodon/features/notifications/components/column_settings.jsx index ed2947c175..9616adcb93 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.jsx +++ b/app/javascript/mastodon/features/notifications/components/column_settings.jsx @@ -38,6 +38,7 @@ class ColumnSettings extends PureComponent { const alertStr = ; const showStr = ; const soundStr = ; + const groupStr = ; const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); const pushStr = showPushSettings && ; @@ -94,6 +95,7 @@ class ColumnSettings extends PureComponent { {showPushSettings && } +
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx deleted file mode 100644 index c288c2c0de..0000000000 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; -import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; -import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; -import StarIcon from '@/material-icons/400-24px/star.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -const tooltips = defineMessages({ - mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, - favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' }, - boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, - polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, - follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, - statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' }, -}); - -class FilterBar extends PureComponent { - - static propTypes = { - selectFilter: PropTypes.func.isRequired, - selectedFilter: PropTypes.string.isRequired, - advancedMode: PropTypes.bool.isRequired, - intl: PropTypes.object.isRequired, - }; - - onClick (notificationType) { - return () => this.props.selectFilter(notificationType); - } - - render () { - const { selectedFilter, advancedMode, intl } = this.props; - const renderedElement = !advancedMode ? ( -
- - -
- ) : ( -
- - - - - - - -
- ); - return renderedElement; - } - -} - -export default injectIntl(FilterBar); diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index 7e3a860e3e..00963b2274 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -18,8 +18,8 @@ import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; +import { Account } from 'mastodon/components/account'; import { Icon } from 'mastodon/components/icon'; -import AccountContainer from 'mastodon/containers/account_container'; import StatusContainer from 'mastodon/containers/status_container'; import { me } from 'mastodon/initial_state'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; @@ -147,7 +147,7 @@ class Notification extends ImmutablePureComponent {
-
); @@ -167,7 +167,7 @@ class Notification extends ImmutablePureComponent {
-
); @@ -420,7 +420,7 @@ class Notification extends ImmutablePureComponent {
-
); diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index 8bcc7ab4ef..4ac6cfa629 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -56,11 +56,12 @@ const mapDispatchToProps = (dispatch) => ({ } else { dispatch(changeSetting(['notifications', ...path], checked)); } - } else if(path[0] === 'groupingBeta') { - dispatch(changeSetting(['notifications', ...path], checked)); - dispatch(initializeNotifications()); } else { dispatch(changeSetting(['notifications', ...path], checked)); + + if(path[0] === 'group' && path[1] === 'follow') { + dispatch(initializeNotifications()); + } } }, diff --git a/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js b/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js deleted file mode 100644 index 4e0184cef3..0000000000 --- a/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; - -import { setFilter } from '../../../actions/notifications'; -import FilterBar from '../components/filter_bar'; - -const makeMapStateToProps = state => ({ - selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']), - advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']), -}); - -const mapDispatchToProps = (dispatch) => ({ - selectFilter (newActiveFilter) { - dispatch(setFilter(newActiveFilter)); - }, -}); - -export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar); diff --git a/app/javascript/mastodon/features/notifications/index.jsx b/app/javascript/mastodon/features/notifications/index.jsx deleted file mode 100644 index cefbd544b0..0000000000 --- a/app/javascript/mastodon/features/notifications/index.jsx +++ /dev/null @@ -1,308 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import { createSelector } from '@reduxjs/toolkit'; -import { List as ImmutableList } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react'; -import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; -import { compareId } from 'mastodon/compare_id'; -import { Icon } from 'mastodon/components/icon'; -import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; - -import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import { submitMarkers } from '../../actions/markers'; -import { - expandNotifications, - scrollTopNotifications, - loadPending, - mountNotifications, - unmountNotifications, - markNotificationsAsRead, -} from '../../actions/notifications'; -import Column from '../../components/column'; -import ColumnHeader from '../../components/column_header'; -import { LoadGap } from '../../components/load_gap'; -import ScrollableList from '../../components/scrollable_list'; - -import { - FilteredNotificationsBanner, - FilteredNotificationsIconButton, -} from './components/filtered_notifications_banner'; -import NotificationsPermissionBanner from './components/notifications_permission_banner'; -import ColumnSettingsContainer from './containers/column_settings_container'; -import FilterBarContainer from './containers/filter_bar_container'; -import NotificationContainer from './containers/notification_container'; - -const messages = defineMessages({ - title: { id: 'column.notifications', defaultMessage: 'Notifications' }, - markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, -}); - -const getExcludedTypes = createSelector([ - state => state.getIn(['settings', 'notifications', 'shows']), -], (shows) => { - return ImmutableList(shows.filter(item => !item).keys()); -}); - -const getNotifications = createSelector([ - state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), - state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), - getExcludedTypes, - state => state.getIn(['notifications', 'items']), -], (showFilterBar, allowedType, excludedTypes, notifications) => { - if (!showFilterBar || allowedType === 'all') { - // used if user changed the notification settings after loading the notifications from the server - // otherwise a list of notifications will come pre-filtered from the backend - // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category - return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); - } - return notifications.filter(item => item === null || allowedType === item.get('type')); -}); - -const mapStateToProps = state => ({ - notifications: getNotifications(state), - isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0, - isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, - hasMore: state.getIn(['notifications', 'hasMore']), - numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, - lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0', - canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), - needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']), -}); - -class Notifications extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - columnId: PropTypes.string, - notifications: ImmutablePropTypes.list.isRequired, - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - isLoading: PropTypes.bool, - isUnread: PropTypes.bool, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - numPending: PropTypes.number, - lastReadId: PropTypes.string, - canMarkAsRead: PropTypes.bool, - needsNotificationPermission: PropTypes.bool, - }; - - static defaultProps = { - trackScroll: true, - }; - - UNSAFE_componentWillMount() { - this.props.dispatch(mountNotifications()); - } - - componentWillUnmount () { - this.handleLoadOlder.cancel(); - this.handleScrollToTop.cancel(); - this.handleScroll.cancel(); - this.props.dispatch(scrollTopNotifications(false)); - this.props.dispatch(unmountNotifications()); - } - - handleLoadGap = (maxId) => { - this.props.dispatch(expandNotifications({ maxId })); - }; - - handleLoadOlder = debounce(() => { - const last = this.props.notifications.last(); - this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); - }, 300, { leading: true }); - - handleLoadPending = () => { - this.props.dispatch(loadPending()); - }; - - handleScrollToTop = debounce(() => { - this.props.dispatch(scrollTopNotifications(true)); - }, 100); - - handleScroll = debounce(() => { - this.props.dispatch(scrollTopNotifications(false)); - }, 100); - - handlePin = () => { - const { columnId, dispatch } = this.props; - - if (columnId) { - dispatch(removeColumn(columnId)); - } else { - dispatch(addColumn('NOTIFICATIONS', {})); - } - }; - - handleMove = (dir) => { - const { columnId, dispatch } = this.props; - dispatch(moveColumn(columnId, dir)); - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setColumnRef = c => { - this.column = c; - }; - - handleMoveUp = id => { - const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; - this._selectChild(elementIndex, true); - }; - - handleMoveDown = id => { - const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; - this._selectChild(elementIndex, false); - }; - - _selectChild (index, align_top) { - const container = this.column.node; - const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); - - if (element) { - if (align_top && container.scrollTop > element.offsetTop) { - element.scrollIntoView(true); - } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { - element.scrollIntoView(false); - } - element.focus(); - } - } - - handleMarkAsRead = () => { - this.props.dispatch(markNotificationsAsRead()); - this.props.dispatch(submitMarkers({ immediate: true })); - }; - - render () { - const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; - const pinned = !!columnId; - const emptyMessage = ; - const { signedIn } = this.props.identity; - - let scrollableContent = null; - - const filterBarContainer = signedIn - ? () - : null; - - if (isLoading && this.scrollableContent) { - scrollableContent = this.scrollableContent; - } else if (notifications.size > 0 || hasMore) { - scrollableContent = notifications.map((item, index) => item === null ? ( - 0 ? notifications.getIn([index - 1, 'id']) : null} - onClick={this.handleLoadGap} - /> - ) : ( - 0} - /> - )); - } else { - scrollableContent = null; - } - - this.scrollableContent = scrollableContent; - - let scrollContainer; - - const prepend = ( - <> - {needsNotificationPermission && } - - - ); - - if (signedIn) { - scrollContainer = ( - - {scrollableContent} - - ); - } else { - scrollContainer = ; - } - - const extraButton = ( - <> - - {canMarkAsRead && ( - - )} - - ); - - return ( - - - - - - {filterBarContainer} - - {scrollContainer} - - - {intl.formatMessage(messages.title)} - - - - ); - } - -} - -export default connect(mapStateToProps)(withIdentity(injectIntl(Notifications))); diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx index 65ea9b5d5e..ca0d1bc850 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx @@ -43,7 +43,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ ); const handleMouseUp = useCallback>( - ({ clientX, clientY, target, button }) => { + ({ clientX, clientY, target, button, ctrlKey, metaKey }) => { const [startX, startY] = clickCoordinatesRef.current ?? [0, 0]; const [deltaX, deltaY] = [ Math.abs(clientX - startX), @@ -64,8 +64,14 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ element = element.parentNode as HTMLDivElement | null; } - if (deltaX + deltaY < 5 && button === 0 && account) { - history.push(`/@${account.acct}/${statusId}`); + if (deltaX + deltaY < 5 && account) { + const path = `/@${account.acct}/${statusId}`; + + if (button === 0 && !(ctrlKey || metaKey)) { + history.push(path); + } else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) { + window.open(path, '_blank', 'noreferrer noopener'); + } } clickCoordinatesRef.current = null; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_annual_report.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_annual_report.tsx new file mode 100644 index 0000000000..8b92f31add --- /dev/null +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_annual_report.tsx @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import CelebrationIcon from '@/material-icons/400-24px/celebration.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import { Icon } from 'mastodon/components/icon'; +import type { NotificationGroupAnnualReport } from 'mastodon/models/notification_group'; +import { useAppDispatch } from 'mastodon/store'; + +export const NotificationAnnualReport: React.FC<{ + notification: NotificationGroupAnnualReport; + unread: boolean; +}> = ({ notification: { annualReport }, unread }) => { + const dispatch = useAppDispatch(); + const year = annualReport.year; + + const handleClick = useCallback(() => { + dispatch( + openModal({ + modalType: 'ANNUAL_REPORT', + modalProps: { year }, + }), + ); + }, [dispatch, year]); + + return ( +
+
+ +
+ +
+

+ +

+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx index 6a9a45d242..78ba260fa9 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx @@ -1,16 +1,21 @@ +import type { JSX } from 'react'; + import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; import { FollowersCounter } from 'mastodon/components/counters'; import { FollowButton } from 'mastodon/components/follow_button'; import { ShortNumber } from 'mastodon/components/short_number'; +import { me } from 'mastodon/initial_state'; import type { NotificationGroupFollow } from 'mastodon/models/notification_group'; import { useAppSelector } from 'mastodon/store'; import type { LabelRenderer } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status'; -const labelRenderer: LabelRenderer = (displayedName, total) => { +const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => { if (total === 1) return ( { return ( + seeMoreHref ? {chunks} : chunks, }} /> ); @@ -46,6 +53,10 @@ export const NotificationFollow: React.FC<{ notification: NotificationGroupFollow; unread: boolean; }> = ({ notification, unread }) => { + const username = useAppSelector( + (state) => state.accounts.getIn([me, 'username']) as string, + ); + let actions: JSX.Element | undefined; let additionalContent: JSX.Element | undefined; @@ -68,6 +79,7 @@ export const NotificationFollow: React.FC<{ timestamp={notification.latest_page_notification_at} count={notification.notifications_count} labelRenderer={labelRenderer} + labelSeeMoreHref={`/@${username}/followers`} unread={unread} actions={actions} additionalContent={additionalContent} diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx index 36f033261a..d5eb851985 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx @@ -9,6 +9,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { NotificationAdminReport } from './notification_admin_report'; import { NotificationAdminSignUp } from './notification_admin_sign_up'; +import { NotificationAnnualReport } from './notification_annual_report'; import { NotificationFavourite } from './notification_favourite'; import { NotificationFollow } from './notification_follow'; import { NotificationFollowRequest } from './notification_follow_request'; @@ -143,6 +144,14 @@ export const NotificationGroup: React.FC<{ /> ); break; + case 'annual_report': + content = ( + + ); + break; default: return null; } diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx index 91f8ae2d40..861556620f 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { JSX } from 'react'; import classNames from 'classnames'; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx index a1c275a1f3..3e6428287d 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx @@ -13,6 +13,7 @@ import { import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import Status from 'mastodon/containers/status_container'; +import { getStatusHidden } from 'mastodon/selectors/filters'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { DisplayedName } from './displayed_name'; @@ -48,6 +49,12 @@ export const NotificationWithStatus: React.FC<{ (state) => state.statuses.getIn([statusId, 'visibility']) === 'direct', ); + const isFiltered = useAppSelector( + (state) => + statusId && + getStatusHidden(state, { id: statusId, contextType: 'notifications' }), + ); + const handlers = useMemo( () => ({ open: () => { @@ -73,7 +80,7 @@ export const NotificationWithStatus: React.FC<{ [dispatch, statusId], ); - if (!statusId) return null; + if (!statusId || isFiltered) return null; return ( diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx index 730d95bcd5..bb476fe51f 100644 --- a/app/javascript/mastodon/features/notifications_v2/index.tsx +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -36,7 +36,8 @@ import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { submitMarkers } from '../../actions/markers'; -import Column from '../../components/column'; +import { Column } from '../../components/column'; +import type { ColumnRef } from '../../components/column'; import { ColumnHeader } from '../../components/column_header'; import { LoadGap } from '../../components/load_gap'; import ScrollableList from '../../components/scrollable_list'; @@ -96,7 +97,7 @@ export const Notifications: React.FC<{ selectNeedsNotificationPermission, ); - const columnRef = useRef(null); + const columnRef = useRef(null); const selectChild = useCallback((index: number, alignTop: boolean) => { const container = columnRef.current?.node as HTMLElement | undefined; diff --git a/app/javascript/mastodon/features/notifications_wrapper.jsx b/app/javascript/mastodon/features/notifications_wrapper.jsx deleted file mode 100644 index 50383d5ebf..0000000000 --- a/app/javascript/mastodon/features/notifications_wrapper.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import Notifications_v2 from 'mastodon/features/notifications_v2'; - -export const NotificationsWrapper = (props) => { - return ( - - ); -}; - -export default NotificationsWrapper; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/components/step.jsx b/app/javascript/mastodon/features/onboarding/components/step.jsx deleted file mode 100644 index a2a1653b8a..0000000000 --- a/app/javascript/mastodon/features/onboarding/components/step.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import PropTypes from 'prop-types'; - -import { Link } from 'react-router-dom'; - -import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react'; -import CheckIcon from '@/material-icons/400-24px/done.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -export const Step = ({ label, description, icon, iconComponent, completed, onClick, href, to }) => { - const content = ( - <> -
- -
- -
-
{label}
-

{description}

-
- -
- {completed ? : } -
- - ); - - if (href) { - return ( - - {content} - - ); - } else if (to) { - return ( - - {content} - - ); - } - - return ( - - ); -}; - -Step.propTypes = { - label: PropTypes.node, - description: PropTypes.node, - icon: PropTypes.string, - iconComponent: PropTypes.func, - completed: PropTypes.bool, - href: PropTypes.string, - to: PropTypes.string, - onClick: PropTypes.func, -}; diff --git a/app/javascript/mastodon/features/onboarding/follows.jsx b/app/javascript/mastodon/features/onboarding/follows.jsx deleted file mode 100644 index e23a335c06..0000000000 --- a/app/javascript/mastodon/features/onboarding/follows.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import { useDispatch } from 'react-redux'; - - -import { fetchSuggestions } from 'mastodon/actions/suggestions'; -import { markAsPartial } from 'mastodon/actions/timelines'; -import { ColumnBackButton } from 'mastodon/components/column_back_button'; -import { EmptyAccount } from 'mastodon/components/empty_account'; -import Account from 'mastodon/containers/account_container'; -import { useAppSelector } from 'mastodon/store'; - -export const Follows = () => { - const dispatch = useDispatch(); - const isLoading = useAppSelector(state => state.getIn(['suggestions', 'isLoading'])); - const suggestions = useAppSelector(state => state.getIn(['suggestions', 'items'])); - - useEffect(() => { - dispatch(fetchSuggestions(true)); - - return () => { - dispatch(markAsPartial('home')); - }; - }, [dispatch]); - - let loadedContent; - - if (isLoading) { - loadedContent = (new Array(8)).fill().map((_, i) => ); - } else if (suggestions.isEmpty()) { - loadedContent =
; - } else { - loadedContent = suggestions.map(suggestion => ); - } - - return ( - <> - - -
-
-

-

-
- -
- {loadedContent} -
- -

{chunks} }} />

- -
- -
-
- - ); -}; diff --git a/app/javascript/mastodon/features/onboarding/follows.tsx b/app/javascript/mastodon/features/onboarding/follows.tsx new file mode 100644 index 0000000000..703a4449be --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/follows.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; + +import { useDebouncedCallback } from 'use-debounce'; + +import PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import { fetchRelationships } from 'mastodon/actions/accounts'; +import { importFetchedAccounts } from 'mastodon/actions/importer'; +import { fetchSuggestions } from 'mastodon/actions/suggestions'; +import { markAsPartial } from 'mastodon/actions/timelines'; +import { apiRequest } from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import { Account } from 'mastodon/components/account'; +import { Column } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { ColumnSearchHeader } from 'mastodon/components/column_search_header'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + title: { + id: 'onboarding.follows.title', + defaultMessage: 'Follow people to get started', + }, + search: { id: 'onboarding.follows.search', defaultMessage: 'Search' }, + back: { id: 'onboarding.follows.back', defaultMessage: 'Back' }, +}); + +type Mode = 'remove' | 'add'; + +export const Follows: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const isLoading = useAppSelector((state) => state.suggestions.isLoading); + const suggestions = useAppSelector((state) => state.suggestions.items); + const [searchAccountIds, setSearchAccountIds] = useState([]); + const [mode, setMode] = useState('remove'); + const [isLoadingSearch, setIsLoadingSearch] = useState(false); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + void dispatch(fetchSuggestions()); + + return () => { + dispatch(markAsPartial('home')); + }; + }, [dispatch]); + + const handleSearchClick = useCallback(() => { + setMode('add'); + }, [setMode]); + + const handleDismissSearchClick = useCallback(() => { + setMode('remove'); + setIsSearching(false); + }, [setMode, setIsSearching]); + + const searchRequestRef = useRef(null); + + const handleSearch = useDebouncedCallback( + (value: string) => { + if (searchRequestRef.current) { + searchRequestRef.current.abort(); + } + + if (value.trim().length === 0) { + setIsSearching(false); + setSearchAccountIds([]); + return; + } + + setIsSearching(true); + setIsLoadingSearch(true); + + searchRequestRef.current = new AbortController(); + + void apiRequest('GET', 'v1/accounts/search', { + signal: searchRequestRef.current.signal, + params: { + q: value, + }, + }) + .then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((a) => a.id))); + setSearchAccountIds(data.map((a) => a.id)); + setIsLoadingSearch(false); + return ''; + }) + .catch(() => { + setIsLoadingSearch(false); + }); + }, + 500, + { leading: true, trailing: true }, + ); + + let displayedAccountIds: string[]; + + if (mode === 'add' && isSearching) { + displayedAccountIds = searchAccountIds; + } else { + displayedAccountIds = suggestions.map( + (suggestion) => suggestion.account_id, + ); + } + + return ( + + + + + + + {displayedAccountIds.length > 0 &&
} + +
+ + + +
+ + } + emptyMessage={ + mode === 'remove' ? ( + + ) : ( + + ) + } + > + {displayedAccountIds.map((accountId) => ( + + ))} + + + + {intl.formatMessage(messages.title)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Follows; diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx deleted file mode 100644 index d100a1c3d5..0000000000 --- a/app/javascript/mastodon/features/onboarding/index.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useCallback } from 'react'; - -import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; - -import { Helmet } from 'react-helmet'; -import { Link, Switch, Route } from 'react-router-dom'; - -import { useDispatch } from 'react-redux'; - - -import illustration from '@/images/elephant_ui_conversation.svg'; -import AccountCircleIcon from '@/material-icons/400-24px/account_circle.svg?react'; -import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react'; -import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; -import EditNoteIcon from '@/material-icons/400-24px/edit_note.svg?react'; -import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; -import { focusCompose } from 'mastodon/actions/compose'; -import { Icon } from 'mastodon/components/icon'; -import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; -import Column from 'mastodon/features/ui/components/column'; -import { me } from 'mastodon/initial_state'; -import { useAppSelector } from 'mastodon/store'; -import { assetHost } from 'mastodon/utils/config'; - -import { Step } from './components/step'; -import { Follows } from './follows'; -import { Profile } from './profile'; -import { Share } from './share'; - -const messages = defineMessages({ - template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' }, -}); - -const Onboarding = () => { - const account = useAppSelector(state => state.getIn(['accounts', me])); - const dispatch = useDispatch(); - const intl = useIntl(); - - const handleComposeClick = useCallback(() => { - dispatch(focusCompose(intl.formatMessage(messages.template))); - }, [dispatch, intl]); - - return ( - - {account ? ( - - -
-
- -

-

-
- -
- 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={} description={} /> - = 1} icon='user-plus' iconComponent={PersonAddIcon} label={} description={} /> - = 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={} description={ }} />} /> - } description={} /> -
- -

- -
- - - - - - - - - -
-
-
- - - - -
- ) : } - - - - -
- ); -}; - -export default Onboarding; diff --git a/app/javascript/mastodon/features/onboarding/profile.jsx b/app/javascript/mastodon/features/onboarding/profile.jsx deleted file mode 100644 index 14250ae39b..0000000000 --- a/app/javascript/mastodon/features/onboarding/profile.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState, useMemo, useCallback, createRef } from 'react'; - -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; -import { useHistory } from 'react-router-dom'; - - -import { useDispatch } from 'react-redux'; - -import Toggle from 'react-toggle'; - -import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react'; -import EditIcon from '@/material-icons/400-24px/edit.svg?react'; -import { updateAccount } from 'mastodon/actions/accounts'; -import { Button } from 'mastodon/components/button'; -import { ColumnBackButton } from 'mastodon/components/column_back_button'; -import { Icon } from 'mastodon/components/icon'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { me } from 'mastodon/initial_state'; -import { useAppSelector } from 'mastodon/store'; -import { unescapeHTML } from 'mastodon/utils/html'; - -const messages = defineMessages({ - uploadHeader: { id: 'onboarding.profile.upload_header', defaultMessage: 'Upload profile header' }, - uploadAvatar: { id: 'onboarding.profile.upload_avatar', defaultMessage: 'Upload profile picture' }, -}); - -const nullIfMissing = path => path.endsWith('missing.png') ? null : path; - -export const Profile = () => { - const account = useAppSelector(state => state.getIn(['accounts', me])); - const [displayName, setDisplayName] = useState(account.get('display_name')); - const [note, setNote] = useState(unescapeHTML(account.get('note'))); - const [avatar, setAvatar] = useState(null); - const [header, setHeader] = useState(null); - const [discoverable, setDiscoverable] = useState(account.get('discoverable')); - const [isSaving, setIsSaving] = useState(false); - const [errors, setErrors] = useState(); - const avatarFileRef = createRef(); - const headerFileRef = createRef(); - const dispatch = useDispatch(); - const intl = useIntl(); - const history = useHistory(); - - const handleDisplayNameChange = useCallback(e => { - setDisplayName(e.target.value); - }, [setDisplayName]); - - const handleNoteChange = useCallback(e => { - setNote(e.target.value); - }, [setNote]); - - const handleDiscoverableChange = useCallback(e => { - setDiscoverable(e.target.checked); - }, [setDiscoverable]); - - const handleAvatarChange = useCallback(e => { - setAvatar(e.target?.files?.[0]); - }, [setAvatar]); - - const handleHeaderChange = useCallback(e => { - setHeader(e.target?.files?.[0]); - }, [setHeader]); - - const avatarPreview = useMemo(() => avatar ? URL.createObjectURL(avatar) : nullIfMissing(account.get('avatar')), [avatar, account]); - const headerPreview = useMemo(() => header ? URL.createObjectURL(header) : nullIfMissing(account.get('header')), [header, account]); - - const handleSubmit = useCallback(() => { - setIsSaving(true); - - dispatch(updateAccount({ - displayName, - note, - avatar, - header, - discoverable, - indexable: discoverable, - })).then(() => history.push('/start/follows')).catch(err => { - setIsSaving(false); - setErrors(err.response.data.details); - }); - }, [dispatch, displayName, note, avatar, header, discoverable, history]); - - return ( - <> - - -
-
-

-

-
- -
-
- - - -
- -
- - -
- -
-
- -
- - -
-