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/.browserslistrc b/.browserslistrc index 6367e4d358..0135379d6e 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,10 +1,6 @@ -[production] defaults > 0.2% firefox >= 78 ios >= 15.6 not dead not OperaMini all - -[development] -supports es6-module diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 5c7263c874..4d5ed0f25f 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -10,6 +10,7 @@ services: RAILS_ENV: development NODE_ENV: development BIND: 0.0.0.0 + BOOTSNAP_CACHE_DIR: /tmp REDIS_HOST: redis REDIS_PORT: '6379' DB_HOST: db @@ -69,7 +70,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/.dockerignore b/.dockerignore index 41da718049..9d990ab9ce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,9 @@ postgres14 redis elasticsearch chart +.yarn/ +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/.env.production.sample b/.env.production.sample index 3dd66abae4..12ab2b6dcb 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -50,7 +50,7 @@ OTP_SECRET= # 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 +# 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= @@ -86,3 +86,24 @@ S3_ALIAS_HOST=files.example.com # ----------------------- IP_RETENTION_PERIOD=31556952 SESSION_RETENTION_PERIOD=31556952 + +# Fetch All Replies Behavior +# -------------------------- +# When a user expands a post (DetailedStatus view), fetch all of its replies +# (default: false) +FETCH_REPLIES_ENABLED=false + +# Period to wait between fetching replies (in minutes) +FETCH_REPLIES_COOLDOWN_MINUTES=15 + +# Period to wait after a post is first created before fetching its replies (in minutes) +FETCH_REPLIES_INITIAL_WAIT_MINUTES=5 + +# Max number of replies to fetch - total, recursively through a whole reply tree +FETCH_REPLIES_MAX_GLOBAL=1000 + +# Max number of replies to fetch - for a single post +FETCH_REPLIES_MAX_SINGLE=500 + +# Max number of replies Collection pages to fetch - total +FETCH_REPLIES_MAX_PAGES=500 diff --git a/.eslintrc.js b/.eslintrc.js index 93ff1d7b59..480b274fad 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -109,7 +109,7 @@ module.exports = defineConfig({ 'react/jsx-equals-spacing': 'error', 'react/jsx-no-bind': 'error', 'react/jsx-no-useless-fragment': 'error', - 'react/jsx-no-target-blank': 'off', + 'react/jsx-no-target-blank': ['error', { allowReferrer: true }], 'react/jsx-tag-spacing': 'error', 'react/jsx-uses-react': 'off', // not needed with new JSX transform 'react/jsx-wrap-multilines': 'error', diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml index a66f5c1076..b868c672f3 100644 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -60,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.3.5) + - Ruby version: (from `ruby --version`, eg. v3.4.1) - 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 index eeb74b160b..fa9bfc7c80 100644 --- a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -61,7 +61,7 @@ body: value: | Please at least include those informations: - Operating system: (eg. Ubuntu 22.04) - - Ruby version: (from `ruby --version`, eg. v3.3.5) + - Ruby version: (from `ruby --version`, eg. v3.4.1) - Node.js version: (from `node --version`, eg. v20.18.0) validations: required: false diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 03a0f5bf37..260730004c 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -1,14 +1,9 @@ on: workflow_call: inputs: - platforms: - required: true - type: string cache: type: boolean default: true - use_native_arm64_builder: - type: boolean push_to_images: type: string version_prerelease: @@ -24,42 +19,36 @@ on: file_to_build: type: string +# This builds multiple images with one runner each, allowing us to build for multiple architectures +# using Github's runners. +# The two-step process is adapted form: +# https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners jobs: + # Build each (amd64 and arm64) image separately build-image: - runs-on: ubuntu-latest + runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder + - name: Prepare + env: + PUSH_TO_IMAGES: ${{ inputs.push_to_images }} + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + # Transform multi-line variable into comma-separated variable + image_names=${PUSH_TO_IMAGES//$'\n'/,} + echo "IMAGE_NAMES=${image_names%,}" >> $GITHUB_ENV - uses: docker/setup-buildx-action@v3 id: buildx - if: ${{ !(inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')) }} - - - name: Start a local Docker Builder - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - run: | - docker run --rm -d --name buildkitd -p 1234:1234 --privileged moby/buildkit:latest --addr tcp://0.0.0.0:1234 - - - uses: docker/setup-buildx-action@v3 - id: buildx-native - if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') - with: - driver: remote - endpoint: tcp://localhost:1234 - platforms: linux/amd64 - append: | - - endpoint: tcp://${{ vars.DOCKER_BUILDER_HETZNER_ARM64_01_HOST }}:13865 - platforms: linux/arm64 - name: mastodon-docker-builder-arm64-01 - driver-opts: - - servername=mastodon-docker-builder-arm64-01 - env: - BUILDER_NODE_1_AUTH_TLS_CACERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CACERT }} - BUILDER_NODE_1_AUTH_TLS_CERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CERT }} - BUILDER_NODE_1_AUTH_TLS_KEY: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_KEY }} - name: Log in to Docker Hub if: contains(inputs.push_to_images, 'tootsuite') @@ -76,8 +65,91 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 + - name: Docker meta id: meta + uses: docker/metadata-action@v5 + if: ${{ inputs.push_to_images != '' }} + with: + images: ${{ inputs.push_to_images }} + flavor: ${{ inputs.flavor }} + labels: ${{ inputs.labels }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ inputs.file_to_build }} + build-args: | + MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} + MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} + SOURCE_COMMIT=${{ github.sha }} + platforms: ${{ matrix.platform }} + provenance: false + push: ${{ inputs.push_to_images != '' }} + cache-from: ${{ inputs.cache && 'type=gha' || '' }} + cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} + outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }} + + - name: Export digest + if: ${{ inputs.push_to_images != '' }} + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + if: ${{ inputs.push_to_images != '' }} + uses: actions/upload-artifact@v4 + with: + # `hashFiles` is used to disambiguate between streaming and non-streaming images + name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # Then merge the docker images into a single one + merge-images: + if: ${{ inputs.push_to_images != '' }} + runs-on: ubuntu-24.04 + needs: + - build-image + + env: + PUSH_TO_IMAGES: ${{ inputs.push_to_images }} + + steps: + - uses: actions/checkout@v4 + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + # `hashFiles` is used to disambiguate between streaming and non-streaming images + pattern: digests-${{ hashFiles(inputs.file_to_build) }}-* + merge-multiple: true + + - name: Log in to Docker Hub + if: contains(inputs.push_to_images, 'tootsuite') + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the GitHub Container registry + if: contains(inputs.push_to_images, 'ghcr.io') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 if: ${{ inputs.push_to_images != '' }} with: images: ${{ inputs.push_to_images }} @@ -85,18 +157,14 @@ jobs: tags: ${{ inputs.tags }} labels: ${{ inputs.labels }} - - uses: docker/build-push-action@v6 - with: - context: . - file: ${{ inputs.file_to_build }} - build-args: | - MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} - MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} - platforms: ${{ inputs.platforms }} - provenance: false - builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} - push: ${{ inputs.push_to_images != '' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: ${{ inputs.cache && 'type=gha' || '' }} - cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + echo "$PUSH_TO_IMAGES" | xargs -I{} \ + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '{}@sha256:%s ' *) + + - name: Inspect image + run: | + echo "$PUSH_TO_IMAGES" | xargs -i{} \ + docker buildx imagetools inspect {}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml index 7c6f74b457..4a56f720e1 100644 --- a/.github/workflows/build-nightly.yml +++ b/.github/workflows/build-nightly.yml @@ -26,8 +26,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon @@ -48,8 +46,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon-streaming diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index d3bc8e5df8..418993475f 100644 --- a/.github/workflows/build-push-pr.yml +++ b/.github/workflows/build-push-pr.yml @@ -32,8 +32,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | ghcr.io/mastodon/mastodon version_metadata: ${{ needs.compute-suffix.outputs.metadata }} @@ -49,8 +47,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | ghcr.io/mastodon/mastodon-streaming version_metadata: ${{ needs.compute-suffix.outputs.metadata }} diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index da9a458282..473718bd10 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -13,8 +13,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | tootsuite/mastodon ghcr.io/mastodon/mastodon @@ -34,8 +32,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true push_to_images: | tootsuite/mastodon-streaming ghcr.io/mastodon/mastodon-streaming diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml index 1e2455d3d9..d3cb4e5e0a 100644 --- a/.github/workflows/build-security.yml +++ b/.github/workflows/build-security.yml @@ -24,8 +24,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon @@ -46,8 +44,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: true cache: false push_to_images: | tootsuite/mastodon-streaming 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 7c1004329c..c46090c1b5 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -18,7 +18,7 @@ permissions: jobs: check-i18n: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest 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 index de21e2e58f..6d9a058629 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -46,11 +46,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.5 + uses: peter-evans/create-pull-request@v7.0.6 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 900899dd52..d247a514d9 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.5 + uses: peter-evans/create-pull-request@v7 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations (automated)' diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 95fcd56942..c1385bf789 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -40,4 +40,4 @@ jobs: uses: ./.github/actions/setup-javascript - name: Stylelint - run: yarn lint:css -f github + run: yarn lint:css --custom-formatter @csstools/stylelint-formatter-github 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/lint-ruby.yml b/.github/workflows/lint-ruby.yml index 277e456146..87f8aee24e 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -9,6 +9,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' @@ -19,6 +20,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml index 980e071897..121b8b0ddf 100644 --- a/.github/workflows/test-image-build.yml +++ b/.github/workflows/test-image-build.yml @@ -8,6 +8,7 @@ on: - .github/workflows/test-image-build.yml - Dockerfile - streaming/Dockerfile + - .dockerignore permissions: contents: read @@ -20,7 +21,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64 # Testing only on native platform so it is performant cache: true build-image-streaming: @@ -31,5 +31,4 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64 # Testing only on native platform so it is performant cache: true diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 5b80fef037..7aab34f0cf 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: @@ -63,7 +64,6 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_CLEAN: true BUNDLE_FROZEN: true @@ -77,6 +77,18 @@ jobs: - name: Set up Ruby environment uses: ./.github/actions/setup-ruby + - name: Ensure no errors with `db:prepare` + run: | + bin/rails db:drop + bin/rails db:prepare + bin/rails db:migrate + + - name: Ensure no errors with `db:prepare` and SKIP_POST_DEPLOYMENT_MIGRATIONS + run: | + bin/rails db:drop + SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:prepare + bin/rails db:migrate + - name: Test "one step migration" flow run: | bin/rails db:drop @@ -90,6 +102,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 c05c8333b2..1f7f8f93a8 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -107,7 +107,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} + COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true @@ -124,8 +124,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' + - '3.3' - '.ruby-version' steps: - uses: actions/checkout@v4 @@ -167,7 +167,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage/lcov/*.lcov env: @@ -175,7 +175,7 @@ jobs: test-libvips: name: Libvips tests - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest needs: - build @@ -208,7 +208,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} + COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true @@ -226,8 +226,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' + - '3.3' - '.ruby-version' steps: - uses: actions/checkout@v4 @@ -254,7 +254,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage/lcov/mastodon.lcov env: @@ -295,7 +295,6 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_WITH: test LOCAL_DOMAIN: localhost:3000 @@ -305,8 +304,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' + - '3.3' - '.ruby-version' steps: @@ -411,7 +410,6 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true RAILS_ENV: test BUNDLE_WITH: test ES_ENABLED: true @@ -422,8 +420,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' + - '3.3' - '.ruby-version' search-image: - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 diff --git a/.nvmrc b/.nvmrc index 10fef252a9..744ca17ec0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.18 +22.14 diff --git a/.prettierignore b/.prettierignore index 6b2f0c1889..80b4c0159e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -63,6 +63,7 @@ docker-compose.override.yml # Ignore emoji map file /app/javascript/mastodon/features/emoji/emoji_map.json +/app/javascript/mastodon/features/emoji/emoji_sheet.json # Ignore locale files /app/javascript/mastodon/locales/*.json diff --git a/.rubocop.yml b/.rubocop.yml index 965f56f3e7..1bbba515af 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 @@ -18,6 +18,7 @@ inherit_from: - .rubocop/rspec_rails.yml - .rubocop/rspec.yml - .rubocop/style.yml + - .rubocop/i18n.yml - .rubocop/custom.yml - .rubocop_todo.yml - .rubocop/strict.yml @@ -26,9 +27,10 @@ inherit_mode: merge: - Exclude -require: +plugins: + - rubocop-capybara + - rubocop-i18n + - rubocop-performance - rubocop-rails - rubocop-rspec - rubocop-rspec_rails - - rubocop-performance - - rubocop-capybara diff --git a/.rubocop/i18n.yml b/.rubocop/i18n.yml new file mode 100644 index 0000000000..de395d3a79 --- /dev/null +++ b/.rubocop/i18n.yml @@ -0,0 +1,12 @@ +I18n/RailsI18n: + Enabled: true + Exclude: + - 'config/**/*' + - 'db/**/*' + - 'lib/**/*' + - 'spec/**/*' +I18n/GetText: + Enabled: false + +I18n/RailsI18n/DecorateStringFormattingUsingInterpolation: + Enabled: false diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml index ae31c1f266..bbd172e656 100644 --- a/.rubocop/rails.yml +++ b/.rubocop/rails.yml @@ -2,6 +2,9 @@ Rails/BulkChangeTable: Enabled: false # Conflicts with strong_migrations features +Rails/Delegate: + Enabled: false + Rails/FilePath: EnforcedStyle: arguments diff --git a/.rubocop/style.yml b/.rubocop/style.yml index 03e35a70ac..f59340d452 100644 --- a/.rubocop/style.yml +++ b/.rubocop/style.yml @@ -1,4 +1,7 @@ --- +Style/ArrayIntersect: + Enabled: false + Style/ClassAndModuleChildren: Enabled: false @@ -19,6 +22,13 @@ Style/HashSyntax: EnforcedShorthandSyntax: either EnforcedStyle: ruby19_no_mixed_keys +Style/IfUnlessModifier: + Exclude: + - '**/*.haml' + +Style/KeywordArgumentsMerging: + Enabled: false + Style/NumericLiterals: AllowedPatterns: - \d{4}_\d{2}_\d{2}_\d{6} @@ -37,6 +47,9 @@ Style/RedundantFetchBlock: Style/RescueStandardError: EnforcedStyle: implicit +Style/SafeNavigationChainLength: + Enabled: false + Style/SymbolArray: Enabled: false @@ -45,3 +58,6 @@ Style/TrailingCommaInArrayLiteral: Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma + +Style/WordArray: + MinSize: 3 # Override default of 2 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a6e51d6aee..4fe2adf83c 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.66.1. +# using RuboCop version 1.73.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -8,7 +8,7 @@ Lint/NonLocalExitFromIterator: Exclude: - - 'app/helpers/jsonld_helper.rb' + - 'app/helpers/json_ld_helper.rb' # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: @@ -35,7 +35,6 @@ Rails/OutputSafety: # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - - 'app/lib/translation_service.rb' - 'config/environments/production.rb' - 'config/initializers/2_limited_federation_mode.rb' - 'config/initializers/3_omniauth.rb' @@ -70,20 +69,11 @@ Style/MapToHash: Exclude: - 'app/models/status.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: literals, strict -Style/MutableConstant: - Exclude: - - 'app/models/tag.rb' - - 'app/services/delete_account_service.rb' - - 'lib/mastodon/migration_warning.rb' - # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: Exclude: - - 'app/helpers/jsonld_helper.rb' + - 'app/helpers/json_ld_helper.rb' - 'app/lib/admin/system_check/message.rb' - 'app/lib/request.rb' - 'app/lib/webfinger.rb' @@ -104,10 +94,3 @@ Style/RedundantConstantBase: Exclude: - 'config/environments/production.rb' - 'config/initializers/sidekiq.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: WordRegex. -# SupportedStyles: percent, brackets -Style/WordArray: - EnforcedStyle: percent - MinSize: 3 diff --git a/.ruby-version b/.ruby-version index fa7adc7ac7..4d9d11cf50 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.5 +3.4.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc5291d72..c7b0f64146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,167 @@ All notable changes to this project will be documented in this file. +## [4.3.6] - 2025-03-13 + +### Security + +- Update dependency `omniauth-saml` +- Update dependency `rack` + +### Fixed + +- Fix Stoplight errors when using `REDIS_NAMESPACE` (#34126 by @ClearlyClaire) + +## [4.3.5] - 2025-03-10 + +### Changed + +- Change hashtag suggestion to prefer personal history capitalization (#34070 by @ClearlyClaire) + +### Fixed + +- Fix processing errors for some HEIF images from iOS 18 (#34086 by @renchap) +- Fix streaming server not filtering unknown-language posts from public timelines (#33774 by @ClearlyClaire) +- Fix preview cards under Content Warnings not being shown in detailed statuses (#34068 by @ClearlyClaire) +- Fix username and display name being hidden on narrow screens in moderation interface (#33064 by @ClearlyClaire) + +## [4.3.4] - 2025-02-27 + +### Security + +- Update dependencies +- Change HTML sanitization to remove unusable and unused `embed` tag (#34021 by @ClearlyClaire, [GHSA-mq2m-hr29-8gqf](https://github.com/mastodon/mastodon/security/advisories/GHSA-mq2m-hr29-8gqf)) +- Fix rate-limit on sign-up email verification ([GHSA-v39f-c9jj-8w7h](https://github.com/mastodon/mastodon/security/advisories/GHSA-v39f-c9jj-8w7h)) +- Fix improper disclosure of domain blocks to unverified users ([GHSA-94h4-fj37-c825](https://github.com/mastodon/mastodon/security/advisories/GHSA-94h4-fj37-c825)) + +### Changed + +- Change preview cards to be shown when Content Warnings are expanded (#33827 by @ClearlyClaire) +- Change warnings against changing encryption secrets to be even more noticeable (#33631 by @ClearlyClaire) +- Change `mastodon:setup` to prevent overwriting already-configured servers (#33603, #33616, and #33684 by @ClearlyClaire and @mjankowski) +- Change notifications from moderators to not be filtered (#32974 and #33654 by @ClearlyClaire and @mjankowski) + +### Fixed + +- Fix `GET /api/v2/notifications/:id` and `POST /api/v2/notifications/:id/dismiss` for ungrouped notifications (#33990 by @ClearlyClaire) +- Fix issue with some versions of libvips on some systems (#33853 by @kleisauke) +- Fix handling of duplicate mentions in incoming status `Update` (#33911 by @ClearlyClaire) +- Fix inefficiencies in timeline generation (#33839 and #33842 by @ClearlyClaire) +- Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818 by @ClearlyClaire) +- Fix `tootctl feeds build` not building list timelines (#33783 by @ClearlyClaire) +- Fix flaky test in `/api/v2/notifications` tests (#33773 by @ClearlyClaire) +- Fix incorrect signature after HTTP redirect (#33757 and #33769 by @ClearlyClaire) +- Fix polls not being validated on edition (#33755 by @ClearlyClaire) +- Fix media preview height in compose form when 3 or more images are attached (#33571 by @ClearlyClaire) +- Fix preview card sizing in “Author attribution” in profile settings (#33482 by @ClearlyClaire) +- Fix processing of incoming notifications for unfilterable types (#33429 by @ClearlyClaire) +- Fix featured tags for remote accounts not being kept up to date (#33372, #33406, and #33425 by @ClearlyClaire and @mjankowski) +- Fix notification polling showing a loading bar in web UI (#32960 by @Gargron) +- Fix accounts table long display name (#29316 by @WebCoder49) +- Fix exclusive lists interfering with notifications (#28162 by @ShadowJonathan) + +## [4.3.3] - 2025-01-16 + +### Security + +- Fix insufficient validation of account URIs ([GHSA-5wxh-3p65-r4g6](https://github.com/mastodon/mastodon/security/advisories/GHSA-5wxh-3p65-r4g6)) +- Update dependencies + +### Fixed + +- Fix `libyaml` missing from `Dockerfile` build stage (#33591 by @vmstan) +- Fix incorrect notification settings migration for non-followers (#33348 by @ClearlyClaire) +- Fix down clause for notification policy v2 migrations (#33340 by @jesseplusplus) +- Fix error decrementing status count when `FeaturedTags#last_status_at` is `nil` (#33320 by @ClearlyClaire) +- Fix last paginated notification group only including data on a single notification (#33271 by @ClearlyClaire) +- Fix processing of mentions for post edits with an existing corresponding silent mention (#33227 by @ClearlyClaire) +- Fix deletion of unconfirmed users with Webauthn set (#33186 by @ClearlyClaire) +- Fix empty authors preview card serialization (#33151, #33466 by @mjankowski and @ClearlyClaire) + +## [4.3.2] - 2024-12-03 + +### Added + +- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire) +- Add error message when user tries to follow their own account (#31910 by @lenikadali) +- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm) + +### Changed + +- Change design of Content Warnings and filters (#32543 by @ClearlyClaire) + +### Fixed + +- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire) +- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer) +- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire) +- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire) +- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire) +- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron) +- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire) +- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire) +- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire) +- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire) +- Fix titles being escaped twice (#32889 by @ClearlyClaire) +- Fix list creation limit check (#32869 by @ClearlyClaire) +- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski) +- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron) +- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire) +- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire) +- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire) +- Fix embed modal layout on mobile (#32641 by @DismalShadowX) +- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro) +- Fix blocks not being applied on link timeline (#32625 by @tribela) +- Fix follow counters being incorrectly changed (#32622 by @oneiros) +- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond) +- Fix tl language native name (#32606 by @seav) + +### Security + +- Update dependencies + +## [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. @@ -26,7 +187,7 @@ 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, #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.\ @@ -51,7 +212,7 @@ The following changelog entries focus on changes visible to users, administrator - **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\ Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\ Note that this does not notify remote users.\ - This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`relationship_severance_event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event). + This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event). - **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\ Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\ This can be disabled in the “Animations and accessibility” section of the preferences. @@ -357,7 +518,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/CONTRIBUTING.md b/CONTRIBUTING.md index 8286fdd2f7..6bdacab4a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,30 +9,51 @@ You can contribute in the following ways: - Contributing code to Mastodon by fixing bugs or implementing features - Improving the documentation -If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). - Please review the org-level [contribution guidelines] for high-level acceptance -criteria guidance. - -[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md +criteria guidance and the [DEVELOPMENT] guide for environment-specific details. ## API Changes and Additions -Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation). +Any changes or additions made to the API should have an accompanying pull +request on our [documentation repository]. -## Bug reports +## Bug Reports -Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/mastodon/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected. +Bug reports and feature suggestions must use descriptive and concise titles and +be submitted to [GitHub Issues]. Please use the search function to make sure +there are not duplicate bug reports or feature requests. ## Translations -You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase. +Translations are community contributed via [Crowdin]. They are periodically +reviewed and merged into the codebase. [![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)](https://crowdin.com/project/mastodon) -## Pull requests +## Pull Requests -**Please use clean, concise titles for your pull requests.** Unless the pull request is about refactoring code, updating dependencies or other internal tasks, assume that the person reading the pull request title is not a programmer or Mastodon developer, but instead a Mastodon user or server administrator, and **try to describe your change or fix from their perspective**. We use commit squashing, so the final commit in the main branch will carry the title of the pull request, and commits from the main branch are fed into the changelog. The changelog is separated into [keepachangelog.com categories](https://keepachangelog.com/en/1.0.0/), and while that spec does not prescribe how the entries ought to be named, for easier sorting, start your pull request titles using one of the verbs "Add", "Change", "Deprecate", "Remove", or "Fix" (present tense). +### Size and Scope + +Our time is limited and PRs making large, unsolicited changes are unlikely to +get a response. Changes which link to an existing confirmed issue, or which come +from a "help wanted" issue or other request are more likely to be reviewed. + +The smaller and more narrowly focused the changes in a PR are, the easier they +are to review and potentially merge. If the change only makes sense in some +larger context of future ongoing work, note that in the description, but still +aim to keep each distinct PR to a "smallest viable change" chunk of work. + +### Description of Changes + +Unless the Pull Request is about refactoring code, updating dependencies or +other internal tasks, assume that the audience are not developers, but a +Mastodon user or server admin, and try to describe it from their perspective. + +The final commit in the main branch will carry the title from the PR. The main +branch is then fed into the changelog and ultimately into release notes. We try +to follow the [keepachangelog] spec, and while that does not prescribe how +exactly the entries ought to be named, starting titles using one of the verbs +"Add", "Change", "Deprecate", "Remove", or "Fix" (present tense) is helpful. Example: @@ -40,16 +61,25 @@ Example: | ------------------------------------ | ------------------------------------------------------------- | | Fixed NoMethodError in RemovalWorker | Fix nil error when removing statuses caused by race condition | -It is not always possible to phrase every change in such a manner, but it is desired. +### Technical Requirements -**The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged.** Splitting tasks into multiple smaller pull requests is often preferable. - -**Pull requests that do not pass automated checks may not be reviewed**. In particular, you need to keep in mind: +Pull requests that do not pass automated checks on CI may not be reviewed. In +particular, please keep in mind: - Unit and integration tests (rspec, jest) - Code style rules (rubocop, eslint) - Normalization of locale files (i18n-tasks) +- Relevant accessibility or performance concerns ## Documentation -The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation). +The [Mastodon documentation] is a statically generated site that contains guides +and API docs. Improvements are made via PRs to the [documentation repository]. + +[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md +[Crowdin]: https://crowdin.com/project/mastodon +[DEVELOPMENT]: docs/DEVELOPMENT.md +[documentation repository]: https://github.com/mastodon/documentation +[GitHub Issues]: https://github.com/mastodon/mastodon/issues +[keepachangelog]: https://keepachangelog.com/en/1.0.0/ +[Mastodon documentation]: https://docs.joinmastodon.org diff --git a/Dockerfile b/Dockerfile index bb29ad21fa..6620f4c096 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -# syntax=docker/dockerfile:1.10 +# 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 +# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/docs/DEVELOPMENT.md#docker # Please see https://docs.docker.com/engine/reference/builder for information about # the extended buildx capabilities used in this file. @@ -9,19 +9,20 @@ # See: https://docs.docker.com/build/building/multi-platform/ ARG TARGETPLATFORM=${TARGETPLATFORM} ARG BUILDPLATFORM=${BUILDPLATFORM} +ARG BASE_REGISTRY="docker.io" -# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"] +# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.3.5" -# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] +ARG RUBY_VERSION="3.4.2" +# # Node.js 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) -FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node -# Ruby image to use for base image based on combined variables (ex: 3.3.x-slim-bookworm) -FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby +# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim) +FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node +# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm) +FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Example: v4.3.0-nightly.2023.11.09+pr-123456 @@ -29,6 +30,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 +48,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.js and related 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,119 +83,110 @@ 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 WORKDIR /opt/mastodon +# Add backport repository for some specific packages where we need the latest version +RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list + # 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 FROM ruby AS build -# Copy Node package configuration files into working directory -COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ -COPY .yarn /opt/mastodon/.yarn - -COPY --from=node /usr/local/bin /usr/local/bin -COPY --from=node /usr/local/lib /usr/local/lib - 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 \ + libyaml-dev \ + meson \ + nasm \ + pkg-config \ + shared-mime-info \ + xz-utils \ + # libvips components + libcgif-dev \ + libexif-dev \ + libexpat1-dev \ + libgirepository1.0-dev \ + libheif-dev/bookworm-backports \ + 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 - rm /usr/local/bin/yarn*; \ - corepack enable; \ - corepack prepare --activate; - # Create temporary libvips specific build layer from build layer 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.5 +ARG VIPS_VERSION=8.16.1 # 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 @@ -228,28 +223,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,58 +258,57 @@ 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 -FROM build AS yarn - -ARG TARGETPLATFORM - -# Copy Node package configuration files into working directory -COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ -COPY streaming/package.json /opt/mastodon/streaming/ -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 - yarn workspaces focus --production @mastodon/mastodon; - # Create temporary assets build layer from build layer FROM build AS precompiler -# Copy Mastodon sources into precompiler layer +ARG TARGETPLATFORM + +# Copy Mastodon sources into layer COPY . /opt/mastodon/ -# Copy bundler and node packages from build layer to container -COPY --from=yarn /opt/mastodon /opt/mastodon/ -COPY --from=bundler /opt/mastodon /opt/mastodon/ -COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/ -# Copy libvips components to layer for precompiler +# Copy Node.js binaries/libraries into layer +COPY --from=node /usr/local/bin /usr/local/bin +COPY --from=node /usr/local/lib /usr/local/lib + +RUN \ + # Configure Corepack + rm /usr/local/bin/yarn*; \ + corepack enable; \ + corepack prepare --activate; + +# 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.js packages + yarn workspaces focus --production @mastodon/mastodon; + +# Copy libvips components into layer for precompiler COPY --from=libvips /usr/local/libvips/bin /usr/local/bin COPY --from=libvips /usr/local/libvips/lib /usr/local/lib - -ARG TARGETPLATFORM +# Copy bundler packages into layer for precompiler +COPY --from=bundler /opt/mastodon /opt/mastodon/ +COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/ 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 +318,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/bookworm-backports \ + 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 +380,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 +390,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 6f4559b00f..fba7371b64 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', '< 3.5.0' gem 'propshaft' gem 'puma', '~> 6.3' gem 'rack', '~> 2.2.7' -gem 'rails', '~> 7.1.1' +gem 'rails', '~> 8.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' @@ -39,7 +39,7 @@ gem 'net-ldap', '~> 0.18' gem 'omniauth', '~> 2.0' gem 'omniauth-cas', '~> 3.0.0.beta.1' -gem 'omniauth_openid_connect', '~> 0.6.1' +gem 'omniauth_openid_connect', '~> 0.8.0' gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'omniauth-saml', '~> 2.0' @@ -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' @@ -62,6 +63,7 @@ gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' 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' @@ -71,13 +73,13 @@ gem 'public_suffix', '~> 6.0' gem 'pundit', '~> 2.3' gem 'rack-attack', '~> 6.6' gem 'rack-cors', '~> 2.0', require: 'rack/cors' -gem 'rails-i18n', '~> 7.0' +gem 'rails-i18n', '~> 8.0' gem 'redcarpet', '~> 3.6' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] gem 'redis-namespace', '~> 1.10' gem 'rqrcode', '~> 2.2' gem 'ruby-progressbar', '~> 1.13' -gem 'sanitize', '~> 6.0' +gem 'sanitize', '~> 7.0' gem 'scenic', '~> 1.7' gem 'sidekiq', '~> 6.5' gem 'sidekiq-bulk', '~> 0.2.0' @@ -92,29 +94,31 @@ gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' gem 'webauthn', '~> 3.0' gem 'webpacker', '~> 5.4' -gem 'webpush', github: 'ClearlyClaire/webpush', ref: 'f14a4d52e201128b1b00245d11b6de80d6cfdcd9' +gem 'webpush', github: 'mastodon/webpush', ref: '9631ac63045cfabddacc69fc06e919b4c13eb913' gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' -gem 'opentelemetry-api', '~> 1.4.0' +gem 'prometheus_exporter', '~> 2.2', require: false + +gem 'opentelemetry-api', '~> 1.5.0' group :opentelemetry do - gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false - gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false - gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false - gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false - gem 'opentelemetry-instrumentation-excon', '~> 0.22.0', require: false - gem 'opentelemetry-instrumentation-faraday', '~> 0.24.1', require: false - gem 'opentelemetry-instrumentation-http', '~> 0.23.2', require: false - 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.25.0', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.32.0', require: false - gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false - gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false + gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.24.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false end @@ -123,7 +127,7 @@ group :test do gem 'flatware-rspec' # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab - gem 'rspec-github', '~> 2.4', require: false + gem 'rspec-github', '~> 3.0', require: false # RSpec helpers for email specs gem 'email_spec' @@ -141,9 +145,6 @@ group :test do # Used to mock environment variables gem 'climate_control' - # Add back helpers functions removed in Rails 5.1 - gem 'rails-controller-testing', '~> 1.0' - # Validate schemas in specs gem 'json-schema', '~> 5.0' @@ -152,7 +153,7 @@ group :test do gem 'shoulda-matchers' - # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false + # Coverage formatter for RSpec gem 'simplecov', '~> 0.22', require: false gem 'simplecov-lcov', '~> 0.8', require: false @@ -164,13 +165,14 @@ group :development do # Code linting CLI and plugins gem 'rubocop', require: false gem 'rubocop-capybara', require: false + gem 'rubocop-i18n', require: false gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false gem 'rubocop-rspec_rails', require: false # Annotates modules with schema - gem 'annotate', '~> 3.2' + gem 'annotaterb', '~> 4.13', require: false # Enhanced error message pages for development gem 'better_errors', '~> 2.9' @@ -181,7 +183,7 @@ group :development do gem 'letter_opener_web', '~> 3.0' # Security analysis CLI tools - gem 'brakeman', '~> 6.0', require: false + gem 'brakeman', '~> 7.0', require: false gem 'bundler-audit', '~> 0.9', require: false # Linter CLI for HAML files @@ -193,7 +195,7 @@ end group :development, :test do # Interactive Debugging tools - gem 'debug', '~> 1.8' + gem 'debug', '~> 1.8', require: false # Generate fake data values gem 'faker', '~> 3.2' @@ -205,7 +207,7 @@ group :development, :test do gem 'memory_profiler', require: false gem 'ruby-prof', require: false gem 'stackprof', require: false - gem 'test-prof' + gem 'test-prof', require: false # RSpec runner for rails gem 'rspec-rails', '~> 7.0' @@ -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.6.0' gem 'rubyzip', '~> 2.3' gem 'hcaptcha', '~> 7.1' diff --git a/Gemfile.lock b/Gemfile.lock index 4c9ccaabd1..66a597e99e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,145 +1,134 @@ GIT - remote: https://github.com/ClearlyClaire/webpush.git - revision: f14a4d52e201128b1b00245d11b6de80d6cfdcd9 - ref: f14a4d52e201128b1b00245d11b6de80d6cfdcd9 + remote: https://github.com/mastodon/webpush.git + revision: 9631ac63045cfabddacc69fc06e919b4c13eb913 + ref: 9631ac63045cfabddacc69fc06e919b4c13eb913 specs: - webpush (0.3.8) + webpush (1.1.0) hkdf (~> 0.2) jwt (~> 2.0) GEM remote: https://rubygems.org/ specs: - actioncable (7.1.4.2) - actionpack (= 7.1.4.2) - activesupport (= 7.1.4.2) + actioncable (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.4.2) - actionpack (= 7.1.4.2) - activejob (= 7.1.4.2) - activerecord (= 7.1.4.2) - activestorage (= 7.1.4.2) - activesupport (= 7.1.4.2) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.4.2) - actionpack (= 7.1.4.2) - actionview (= 7.1.4.2) - activejob (= 7.1.4.2) - activesupport (= 7.1.4.2) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + actionmailer (8.0.2) + actionpack (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.4.2) - actionview (= 7.1.4.2) - activesupport (= 7.1.4.2) + actionpack (8.0.2) + actionview (= 8.0.2) + activesupport (= 8.0.2) nokogiri (>= 1.8.5) - racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.4.2) - actionpack (= 7.1.4.2) - activerecord (= 7.1.4.2) - activestorage (= 7.1.4.2) - activesupport (= 7.1.4.2) + useragent (~> 0.16) + actiontext (8.0.2) + actionpack (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.4.2) - activesupport (= 7.1.4.2) + actionview (8.0.2) + activesupport (= 8.0.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.2) - activesupport (= 7.1.4.2) + activejob (8.0.2) + activesupport (= 8.0.2) globalid (>= 0.3.6) - activemodel (7.1.4.2) - activesupport (= 7.1.4.2) - activerecord (7.1.4.2) - activemodel (= 7.1.4.2) - activesupport (= 7.1.4.2) + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) timeout (>= 0.4.0) - activestorage (7.1.4.2) - actionpack (= 7.1.4.2) - activejob (= 7.1.4.2) - activerecord (= 7.1.4.2) - activesupport (= 7.1.4.2) + activestorage (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activesupport (= 8.0.2) marcel (~> 1.0) - activesupport (7.1.4.2) + activesupport (8.0.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) + uri (>= 0.13.1) 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.14.0) ast (2.4.2) attr_required (1.0.2) - awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.992.0) - aws-sdk-core (3.210.0) + aws-partitions (1.1032.0) + aws-sdk-core (3.214.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.95.0) + aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.169.0) + aws-sdk-s3 (1.177.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.4) + 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) rouge (>= 1.0.0) - bigdecimal (3.1.8) + bigdecimal (3.1.9) bindata (2.5.0) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.8) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.2) + brakeman (7.0.0) 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) @@ -170,8 +159,8 @@ GEM climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) @@ -179,15 +168,15 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.19.0) + css_parser (1.21.0) addressable - csv (3.3.0) + csv (3.3.2) 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) - debug (1.9.2) + date (3.4.1) + debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) @@ -197,22 +186,22 @@ 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) + diff-lcs (1.6.0) + 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.1) railties (>= 5) - dotenv (3.1.4) + dotenv (3.1.7) drb (2.2.1) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) @@ -228,59 +217,46 @@ GEM htmlentities (~> 4.3.3) launchy (>= 2.1, < 4.0) mail (~> 2.7) - erubi (1.13.0) + email_validator (2.2.4) + activemodel + erubi (1.13.1) et-orbi (1.2.11) tzinfo - excon (0.111.0) + excon (1.2.3) fabrication (2.31.0) 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.2) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-httpclient (2.0.1) + httpclient (>= 2.2) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) fast_blank (1.0.1) - fastimage (2.3.1) - ffi (1.16.3) + fastimage (2.4.0) + ffi (1.17.1) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake - flatware (2.3.3) + flatware (2.3.4) drb thor (< 2.0) - flatware-rspec (2.3.3) - flatware (= 2.3.3) + flatware-rspec (2.3.4) + flatware (= 2.3.4) rspec (>= 3.6) - fog-core (2.5.0) + fog-core (2.6.0) builder - excon (~> 0.71) + excon (~> 1.0) formatador (>= 0.2, < 2.0) mime-types fog-json (1.2.0) fog-core multi_json (~> 1.10) - fog-openstack (1.1.3) + fog-openstack (1.1.4) fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) @@ -289,8 +265,8 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (3.25.5) - googleapis-common-protos-types (1.15.0) + google-protobuf (3.25.6) + googleapis-common-protos-types (1.18.0) google-protobuf (>= 3.18, < 5.a) haml (6.3.0) temple (>= 0.8.2) @@ -301,17 +277,17 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.59.0) + haml_lint (0.61.0) haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.1.1) + hashdiff (1.1.2) hashie (5.0.0) hcaptcha (7.1.0) json - highline (3.1.1) + highline (3.1.2) reline hiredis (0.6.3) hkdf (0.3.0) @@ -322,7 +298,7 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) @@ -330,9 +306,9 @@ GEM httplog (1.7.0) rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.14) + i18n-tasks (1.0.15) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -341,23 +317,31 @@ GEM parser (>= 3.2.2.1) rails-i18n rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) idn-ruby (0.1.5) inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.7.2) - irb (1.14.1) + io-console (0.8.0) + irb (1.15.1) + pp (>= 0.6.0) 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.10.2) json-canonicalization (1.0.0) - json-jwt (1.15.3.1) + json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap + base64 bindata - httpclient + faraday (~> 2.0) + faraday-follow_redirects json-ld (3.3.2) htmlentities (~> 4.3) json-canonicalization (~> 1.0) @@ -366,13 +350,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.1) + json-schema (5.1.1) addressable (~> 2.8) + bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.7.1) + jwt (2.10.1) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -391,10 +377,11 @@ GEM marcel (~> 1.0.1) mime-types terrapin (>= 0.6.0, < 2.0) - language_server-protocol (3.17.0.3) - launchy (3.0.1) + language_server-protocol (3.17.0.4) + launchy (3.1.0) addressable (~> 2.8) childprocess (~> 5.0) + logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -403,16 +390,17 @@ GEM railties (>= 6.1) rexml link_header (0.0.8) + lint_roller (1.1.0) llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) - logger (1.6.1) + logger (1.6.6) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.22.0) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -424,27 +412,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.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.1001) + mime-types-data (3.2025.0220) 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.4) + msgpack (1.8.0) 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.6.0) uri - net-http-persistent (4.0.2) - connection_pool (~> 2.2) - net-imap (0.4.15) + net-imap (0.5.6) date net-protocol net-ldap (0.19.0) @@ -452,140 +433,150 @@ GEM net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol - nio4r (2.7.3) - nokogiri (1.16.7) + nio4r (2.7.4) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.6) + oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.2) + omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-cas (3.0.0) + omniauth-cas (3.0.1) addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.2.1) + omniauth-saml (2.2.3) omniauth (~> 2.1) - ruby-saml (~> 1.17) - omniauth_openid_connect (0.6.1) + ruby-saml (~> 1.18) + omniauth_openid_connect (0.8.0) omniauth (>= 1.9, < 3) - openid_connect (~> 1.1) - openid_connect (1.4.2) + openid_connect (~> 2.2) + openid_connect (2.3.1) activemodel attr_required (>= 1.0.0) - json-jwt (>= 1.15.0) - net-smtp - rack-oauth2 (~> 1.21) - swd (~> 1.3) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) tzinfo - validate_email validate_url - webfinger (~> 1.2) - openssl (3.2.0) + webfinger (~> 2.0) + openssl (3.3.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.4.0) - opentelemetry-common (0.21.0) + opentelemetry-api (1.5.0) + opentelemetry-common (0.22.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.29.0) + opentelemetry-exporter-otlp (0.30.0) 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.3.0) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.2.0) + opentelemetry-instrumentation-action_mailer (0.4.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-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-action_pack (0.12.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.7.2) + opentelemetry-instrumentation-action_view (0.9.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.1) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_job (0.7.8) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_job (0.8.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_model_serializers (0.20.2) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_model_serializers (0.22.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_record (0.8.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_record (0.9.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_support (0.6.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_storage (0.1.1) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-base (0.22.6) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_support (0.8.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-base (0.23.0) opentelemetry-api (~> 1.0) opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-concurrent_ruby (0.21.4) + opentelemetry-instrumentation-concurrent_ruby (0.22.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.4) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-excon (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.6) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-faraday (0.26.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.4) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-http (0.24.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-http_client (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-net_http (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.29.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-pg (0.30.0) opentelemetry-api (~> 1.0) opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (0.25.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rack (0.26.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.32.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rails (0.36.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.2.0) - opentelemetry-instrumentation-action_pack (~> 0.9.0) - opentelemetry-instrumentation-action_view (~> 0.7.0) - opentelemetry-instrumentation-active_job (~> 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) + opentelemetry-instrumentation-action_mailer (~> 0.4.0) + opentelemetry-instrumentation-action_pack (~> 0.12.0) + opentelemetry-instrumentation-action_view (~> 0.9.0) + opentelemetry-instrumentation-active_job (~> 0.8.0) + opentelemetry-instrumentation-active_record (~> 0.9.0) + opentelemetry-instrumentation-active_storage (~> 0.1.0) + opentelemetry-instrumentation-active_support (~> 0.8.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) + opentelemetry-instrumentation-redis (0.26.1) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-sidekiq (0.25.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-sidekiq (0.26.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-registry (0.3.1) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.5.0) + opentelemetry-sdk (1.8.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.10.1) + opentelemetry-semantic_conventions (1.11.0) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) - ostruct (0.6.0) - ox (2.14.18) + ostruct (0.6.1) + ox (2.14.22) + bigdecimal (>= 3.0) parallel (1.26.3) - parser (3.3.5.0) + parser (3.3.7.1) ast (~> 2.4.1) racc parslet (2.0.0) @@ -594,6 +585,8 @@ GEM pg (1.5.9) pghero (3.6.1) activerecord (>= 6.1) + pp (0.6.2) + prettyprint premailer (1.27.0) addressable css_parser (>= 1.19.0) @@ -602,29 +595,34 @@ GEM actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) + prettyprint (0.2.0) + prometheus_exporter (2.2.0) + webrick propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.1.2) + psych (5.2.3) + date stringio public_suffix (6.0.1) - puma (6.4.3) + puma (6.6.0) nio4r (~> 2.0) - pundit (2.4.0) + pundit (2.5.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.10) + rack (2.2.13) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) rack (>= 2.0.0) - rack-oauth2 (1.21.3) + rack-oauth2 (2.2.1) activesupport attr_required - httpclient + faraday (~> 2.0) + faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) rack-protection (3.2.0) @@ -634,43 +632,39 @@ GEM rack rack-session (1.0.2) rack (< 3) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rackup (1.0.0) + rackup (1.0.1) rack (< 3) webrick - rails (7.1.4.2) - actioncable (= 7.1.4.2) - actionmailbox (= 7.1.4.2) - actionmailer (= 7.1.4.2) - actionpack (= 7.1.4.2) - actiontext (= 7.1.4.2) - actionview (= 7.1.4.2) - activejob (= 7.1.4.2) - activemodel (= 7.1.4.2) - activerecord (= 7.1.4.2) - activestorage (= 7.1.4.2) - activesupport (= 7.1.4.2) + rails (8.0.2) + actioncable (= 8.0.2) + actionmailbox (= 8.0.2) + actionmailer (= 8.0.2) + actionpack (= 8.0.2) + actiontext (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activemodel (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) bundler (>= 1.15.0) - railties (= 7.1.4.2) - rails-controller-testing (1.0.5) - actionpack (>= 5.0.1.rc1) - actionview (>= 5.0.1.rc1) - activesupport (>= 5.0.1.rc1) + railties (= 8.0.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) 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 (8.0.1) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 8) - railties (7.1.4.2) - actionpack (= 7.1.4.2) - activesupport (= 7.1.4.2) - irb + railties (>= 8.0.0, < 9) + railties (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) @@ -683,25 +677,25 @@ GEM link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.7.0) + rdoc (6.12.0) psych (>= 4.0.0) - redcarpet (3.6.0) + redcarpet (3.6.1) redis (4.8.1) redis-namespace (1.11.0) redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.9.2) - reline (0.5.10) + regexp_parser (2.10.0) + reline (0.6.0) io-console (~> 0.5) - request_store (1.6.0) + request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.8) + rexml (3.4.1) rotp (6.3.0) - rouge (4.3.0) + rouge (4.5.1) rpam2 (4.0.2) rqrcode (2.2.0) chunky_png (~> 1.0) @@ -711,17 +705,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.3) 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-github (3.0.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.1) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -729,66 +723,75 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-sidekiq (5.0.0) + rspec-sidekiq (5.1.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) - sidekiq (>= 5, < 8) - rspec-support (3.13.1) - rubocop (1.66.1) + sidekiq (>= 5, < 9) + rspec-support (3.13.2) + rubocop (1.74.0) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.38.1) parser (>= 3.3.1.0) - rubocop-capybara (2.21.0) - rubocop (~> 1.41) - rubocop-performance (1.22.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.26.2) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-i18n (3.2.3) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-performance (1.24.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.30.3) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.52.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.1.0) - rubocop (~> 1.61) - rubocop-rspec_rails (2.30.0) - rubocop (~> 1.61) - rubocop-rspec (~> 3, >= 3.0.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rspec (3.5.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec_rails (2.31.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) ruby-prof (1.7.1) ruby-progressbar (1.13.0) - ruby-saml (1.17.0) + ruby-saml (1.18.0) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.2) + ruby-vips (2.2.3) ffi (~> 1.12) logger - ruby2_keywords (0.0.5) - rubyzip (2.3.2) - rufus-scheduler (3.9.1) - fugit (~> 1.1, >= 1.1.6) + rubyzip (2.4.1) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.1.3) + sanitize (7.0.0) crass (~> 1.0.2) - nokogiri (>= 1.12.0) + nokogiri (>= 1.16.8) scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - selenium-webdriver (4.25.0) + securerandom (0.4.1) + selenium-webdriver (4.29.1) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - semantic_range (3.0.0) + semantic_range (3.1.0) shoulda-matchers (6.4.0) activesupport (>= 5.2.0) sidekiq (6.5.12) @@ -819,27 +822,28 @@ GEM simplecov-html (0.13.1) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) - stackprof (0.2.26) - stoplight (4.1.0) + stackprof (0.2.27) + stoplight (4.1.1) redlock (~> 1.0) - stringio (3.1.1) - strong_migrations (2.0.1) - activerecord (>= 6.1) - swd (1.3.0) + stringio (3.1.5) + strong_migrations (2.2.0) + activerecord (>= 7) + swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects sysexits (1.2.0) temple (0.10.3) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) terrapin (1.0.1) climate_control - test-prof (1.4.2) + test-prof (1.4.4) thor (1.3.2) - tilt (2.4.0) - timeout (0.4.1) - tpm-key_attestation (0.12.1) + tilt (2.6.0) + timeout (0.4.3) + tpm-key_attestation (0.14.0) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) @@ -858,34 +862,34 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2024.2) + tzinfo-data (1.2025.1) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (2.6.0) - uri (0.13.1) - validate_email (0.1.6) - activemodel (>= 3.0) - mail (>= 2.2.5) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.3) + useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix warden (1.2.9) rack (>= 2.0.9) - webauthn (3.1.0) + webauthn (3.4.0) android_key_attestation (~> 0.3.0) - awrence (~> 1.1) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) openssl (>= 2.2) safety_net_attestation (~> 0.4.0) - tpm-key_attestation (~> 0.12.0) - webfinger (1.2.0) + tpm-key_attestation (~> 0.14.0) + webfinger (2.1.3) activesupport - httpclient (>= 2.4) - webmock (3.24.0) + faraday (~> 2.0) + faraday-follow_redirects + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -894,16 +898,17 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webrick (1.8.2) + webrick (1.9.1) websocket (1.2.11) - websocket-driver (0.7.6) + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.1) xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.18) + zeitwerk (2.7.2) PLATFORMS ruby @@ -911,14 +916,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) + brakeman (~> 7.0) + browser bundler-audit (~> 0.9) capybara (~> 3.39) charlock_holmes (~> 0.7.7) @@ -940,10 +945,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 @@ -958,6 +964,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) @@ -969,10 +976,10 @@ DEPENDENCIES lograge (~> 0.12) mail (~> 2.8) mario-redis-lock (~> 1.2) - md-paperclip-azure (~> 2.2) memory_profiler mime-types (~> 3.6.0) - net-http (~> 0.4.0) + mutex_m + net-http (~> 0.6.0) net-ldap (~> 0.18) nokogiri (~> 1.15) oj (~> 3.14) @@ -980,28 +987,29 @@ DEPENDENCIES omniauth-cas (~> 3.0.0.beta.1) omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) - omniauth_openid_connect (~> 0.6.1) - opentelemetry-api (~> 1.4.0) - opentelemetry-exporter-otlp (~> 0.29.0) - opentelemetry-instrumentation-active_job (~> 0.7.1) - opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) - opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2) - opentelemetry-instrumentation-excon (~> 0.22.0) - opentelemetry-instrumentation-faraday (~> 0.24.1) - opentelemetry-instrumentation-http (~> 0.23.2) - opentelemetry-instrumentation-http_client (~> 0.22.3) - opentelemetry-instrumentation-net_http (~> 0.22.4) - opentelemetry-instrumentation-pg (~> 0.29.0) - opentelemetry-instrumentation-rack (~> 0.25.0) - opentelemetry-instrumentation-rails (~> 0.32.0) - opentelemetry-instrumentation-redis (~> 0.25.3) - opentelemetry-instrumentation-sidekiq (~> 0.25.2) + omniauth_openid_connect (~> 0.8.0) + opentelemetry-api (~> 1.5.0) + opentelemetry-exporter-otlp (~> 0.30.0) + opentelemetry-instrumentation-active_job (~> 0.8.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) + opentelemetry-instrumentation-excon (~> 0.23.0) + opentelemetry-instrumentation-faraday (~> 0.26.0) + opentelemetry-instrumentation-http (~> 0.24.0) + opentelemetry-instrumentation-http_client (~> 0.23.0) + opentelemetry-instrumentation-net_http (~> 0.23.0) + opentelemetry-instrumentation-pg (~> 0.30.0) + opentelemetry-instrumentation-rack (~> 0.26.0) + opentelemetry-instrumentation-rails (~> 0.36.0) + opentelemetry-instrumentation-redis (~> 0.26.0) + opentelemetry-instrumentation-sidekiq (~> 0.26.0) opentelemetry-sdk (~> 1.4) ox (~> 2.14) parslet pg (~> 1.5) pghero premailer-rails + prometheus_exporter (~> 2.2) propshaft public_suffix (~> 6.0) puma (~> 6.3) @@ -1010,19 +1018,19 @@ DEPENDENCIES rack-attack (~> 6.6) rack-cors (~> 2.0) rack-test (~> 2.1) - rails (~> 7.1.1) - rails-controller-testing (~> 1.0) - rails-i18n (~> 7.0) + rails (~> 8.0) + rails-i18n (~> 8.0) rdf-normalize (~> 0.5) redcarpet (~> 3.6) redis (~> 4.5) redis-namespace (~> 1.10) rqrcode (~> 2.2) - rspec-github (~> 2.4) + rspec-github (~> 3.0) rspec-rails (~> 7.0) rspec-sidekiq (~> 5.0) rubocop rubocop-capybara + rubocop-i18n rubocop-performance rubocop-rails rubocop-rspec @@ -1031,7 +1039,7 @@ DEPENDENCIES ruby-progressbar (~> 1.13) ruby-vips (~> 2.2) rubyzip (~> 2.3) - sanitize (~> 6.0) + sanitize (~> 7.0) scenic (~> 1.7) selenium-webdriver shoulda-matchers @@ -1058,7 +1066,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.3.5p100 + ruby 3.4.1p0 BUNDLED WITH - 2.5.22 + 2.6.5 diff --git a/README.md b/README.md index 9c0b0d20ed..8a34754576 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ -

- - - Mastodon -

+> [!NOTE] +> Want to learn more about Mastodon? +> Click below to find out more in a video. -[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases] -[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml) -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] +

+ + Mastodon hero image + +

-[releases]: https://github.com/mastodon/mastodon/releases -[crowdin]: https://crowdin.com/project/mastodon +

+ + Release + + Ruby Testing + + Crowdin +

Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) -Click below to **learn more** in a video: - -[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][youtube_demo] - -[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE - ## Navigation - [Project homepage 🐘](https://joinmastodon.org) @@ -37,25 +37,15 @@ Click below to **learn more** in a video: -### No vendor lock-in: Fully interoperable with any conforming platform +**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/) -It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/) +**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! -### Real-time, chronological timeline updates +**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously! -Updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! +**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) -### Media attachments like images and short videos - -Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously! - -### Safety and moderation tools - -Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) - -### OAuth2 and a straightforward REST API - -Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices! +**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices! ## Deployment @@ -69,90 +59,45 @@ 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. -## Development - -### Vagrant - -A **Vagrant** configuration is included for development purposes. To use it, complete the following steps: - -- Install Vagrant and Virtualbox -- Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater` -- Run `vagrant up` -- Run `vagrant ssh -c "cd /vagrant && bin/dev"` -- Open `http://mastodon.local` in your browser - -### macOS - -To set up **macOS** for native development, complete the following steps: - -- Install [Homebrew] and run `brew install postgresql@14 redis imagemagick -libidn nvm` to install the required project dependencies -- Use a Ruby version manager to activate the ruby in `.ruby-version` and run - `nvm use` to activate the node version from `.nvmrc` -- Run the `bin/setup` script, which will install the required ruby gems and node - packages and prepare the database for local development -- Finally, run the `bin/dev` script which will launch services via `overmind` - (if installed) or `foreman` - -### Docker - -For production hosting and deployment with **Docker**, use the `Dockerfile` and -`docker-compose.yml` in the project root directory. - -For local development, install and launch [Docker], and run: - -```shell -docker compose -f .devcontainer/compose.yaml up -d -docker compose -f .devcontainer/compose.yaml exec app bin/setup -docker compose -f .devcontainer/compose.yaml exec app bin/dev -``` - -### Dev Containers - -Within IDEs that support the [Development Containers] specification, start the -"Mastodon on local machine" container from the editor. The necessary `docker -compose` commands to build and setup the container should run automatically. For -**Visual Studio Code** this requires installing the [Dev Container extension]. - -### GitHub Codespaces - -[GitHub Codespaces] provides a web-based version of VS Code and a cloud hosted -development environment configured with the software needed for this project. - -[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)][codespace] - -- Click the button to create a new codespace, and confirm the options -- Wait for the environment to build (takes a few minutes) -- When the editor is ready, run `bin/dev` in the terminal -- Wait for an _Open in Browser_ prompt. This will open Mastodon -- On the _Ports_ tab "stream" setting change _Port visibility_ → _Public_ - ## Contributing Mastodon is **free, open-source software** licensed under **AGPLv3**. -You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). +You can open issues for bugs you've found or features you think are missing. You +can also submit pull requests to this repository or translations via Crowdin. To +get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes +accepted into Mastodon, you can request to be paid through our [OpenCollective]. -**IRC channel**: #mastodon on irc.libera.chat +**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat) ## License -Copyright (C) 2016-2024 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) +Copyright (c) 2016-2024 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md)) -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE): -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +``` +Copyright (c) 2016-2024 Eugen Rochko & other Mastodon contributors -You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) any +later version. -[codespace]: https://codespaces.new/mastodon/mastodon?quickstart=1&devcontainer_path=.devcontainer%2Fcodespaces%2Fdevcontainer.json -[Dev Container extension]: https://containers.dev/supporting#dev-containers -[Development Containers]: https://containers.dev/supporting -[Docker]: https://docs.docker.com -[GitHub Codespaces]: https://docs.github.com/en/codespaces -[Homebrew]: https://brew.sh +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see https://www.gnu.org/licenses/ +``` + +[CONTRIBUTING]: CONTRIBUTING.md +[DEVELOPMENT]: docs/DEVELOPMENT.md +[OpenCollective]: https://opencollective.com/mastodon 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/Vagrantfile b/Vagrantfile index 89f5536edc..ce456060cd 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -174,7 +174,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| if config.vm.networks.any? { |type, options| type == :private_network } config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1'] else - config.vm.synced_folder ".", "/vagrant" + config.vm.synced_folder ".", "/vagrant", type: "rsync", create: true, rsync__args: ["--verbose", "--archive", "--delete", "-z"] end # Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080 diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index ab1b98e646..c80db3500d 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -49,7 +49,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def collection_presenter ActivityPub::CollectionPresenter.new( - id: account_collection_url(@account, params[:id]), + id: ActivityPub::TagManager.instance.collection_uri_for(@account, params[:id]), type: @type, size: @size, items: @items diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index b8baf64e1a..a9476b806f 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -41,12 +41,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController end end - def outbox_url(**kwargs) - if params[:account_username].present? - account_outbox_url(@account, **kwargs) - else - instance_actor_outbox_url(**kwargs) - end + def outbox_url(...) + ActivityPub::TagManager.instance.outbox_uri_for(@account, ...) end def next_page diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index e674bf55a0..91849811e3 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -34,7 +34,8 @@ module Admin end def resource_params - params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses) + params + .expect(admin_account_action: [:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses]) end end end diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb index a3c4adf59a..7f65ced517 100644 --- a/app/controllers/admin/account_moderation_notes_controller.rb +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -29,10 +29,8 @@ module Admin private def resource_params - params.require(:account_moderation_note).permit( - :content, - :target_account_id - ) + params + .expect(account_moderation_note: [:content, :target_account_id]) end def set_account_moderation_note diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 7b169ba26a..10391aa3e2 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -158,7 +158,8 @@ module Admin end def form_account_batch_params - params.require(:form_account_batch).permit(:action, account_ids: []) + params + .expect(form_account_batch: [:action, account_ids: []]) end def action_from_button diff --git a/app/controllers/admin/announcements/distributions_controller.rb b/app/controllers/admin/announcements/distributions_controller.rb new file mode 100644 index 0000000000..4bd8769834 --- /dev/null +++ b/app/controllers/admin/announcements/distributions_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Admin::Announcements::DistributionsController < Admin::BaseController + before_action :set_announcement + + def create + authorize @announcement, :distribute? + @announcement.touch(:notification_sent_at) + Admin::DistributeAnnouncementNotificationWorker.perform_async(@announcement.id) + redirect_to admin_announcements_path + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements/previews_controller.rb b/app/controllers/admin/announcements/previews_controller.rb new file mode 100644 index 0000000000..d77f931a7f --- /dev/null +++ b/app/controllers/admin/announcements/previews_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Admin::Announcements::PreviewsController < Admin::BaseController + before_action :set_announcement + + def show + authorize @announcement, :distribute? + @user_count = @announcement.scope_for_notification.count + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements/tests_controller.rb b/app/controllers/admin/announcements/tests_controller.rb new file mode 100644 index 0000000000..f2457eb23a --- /dev/null +++ b/app/controllers/admin/announcements/tests_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Admin::Announcements::TestsController < Admin::BaseController + before_action :set_announcement + + def create + authorize @announcement, :distribute? + UserMailer.announcement_published(current_user, @announcement).deliver_later! + redirect_to admin_announcements_path + end + + private + + def set_announcement + @announcement = Announcement.find(params[:announcement_id]) + end +end diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb index 12230a6506..eaf84aab25 100644 --- a/app/controllers/admin/announcements_controller.rb +++ b/app/controllers/admin/announcements_controller.rb @@ -84,6 +84,7 @@ class Admin::AnnouncementsController < Admin::BaseController end def resource_params - params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day) + params + .expect(announcement: [:text, :scheduled_at, :starts_at, :ends_at, :all_day]) end end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 48685db17a..14338dd293 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -7,14 +7,14 @@ module Admin layout 'admin' - before_action :set_cache_headers + before_action :set_referrer_policy_header after_action :verify_authorized private - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) + def set_referrer_policy_header + response.headers['Referrer-Policy'] = 'same-origin' end def set_user diff --git a/app/controllers/admin/change_emails_controller.rb b/app/controllers/admin/change_emails_controller.rb index a689d3a530..c923b94b1a 100644 --- a/app/controllers/admin/change_emails_controller.rb +++ b/app/controllers/admin/change_emails_controller.rb @@ -41,9 +41,8 @@ module Admin end def resource_params - params.require(:user).permit( - :unconfirmed_email - ) + params + .expect(user: [:unconfirmed_email]) end end end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 00d069cdfb..e3da834fcd 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -44,7 +44,8 @@ module Admin private def resource_params - params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) + params + .expect(custom_emoji: [:shortcode, :image, :visible_in_picker]) end def filtered_custom_emojis @@ -74,7 +75,8 @@ module Admin end def form_custom_emoji_batch_params - params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: []) + params + .expect(form_custom_emoji_batch: [:action, :category_id, :category_name, custom_emoji_ids: []]) end end end diff --git a/app/controllers/admin/domain_allows_controller.rb b/app/controllers/admin/domain_allows_controller.rb index b0f139e3a8..913c1a8246 100644 --- a/app/controllers/admin/domain_allows_controller.rb +++ b/app/controllers/admin/domain_allows_controller.rb @@ -37,6 +37,7 @@ class Admin::DomainAllowsController < Admin::BaseController end def resource_params - params.require(:domain_allow).permit(:domain) + params + .expect(domain_allow: [:domain]) end end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 16a8cb9eea..c3443b7077 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -25,7 +25,9 @@ module Admin rescue Mastodon::NotPermittedError flash[:alert] = I18n.t('admin.domain_blocks.not_permitted') else - redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') + flash[:notice] = I18n.t('admin.domain_blocks.created_msg') + ensure + redirect_to admin_instances_path(limited: '1') end def new @@ -114,7 +116,12 @@ module Admin end def form_domain_block_batch_params - params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate]) + params + .expect( + form_domain_block_batch: [ + domain_blocks_attributes: [[:enabled, :domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate]], + ] + ) end def action_from_button diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index faa0a061a6..12f221164f 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,18 +58,17 @@ 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 - params.require(:email_domain_block).permit(:domain, :allow_with_approval, other_domains: []) + params + .expect(email_domain_block: [:domain, :allow_with_approval, other_domains: []]) end def form_email_domain_block_batch_params - params.require(:form_email_domain_block_batch).permit(email_domain_block_ids: []) + params + .expect(form_email_domain_block_batch: [email_domain_block_ids: []]) end def action_from_button diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb index a54e41bd8c..b060cfbe94 100644 --- a/app/controllers/admin/follow_recommendations_controller.rb +++ b/app/controllers/admin/follow_recommendations_controller.rb @@ -37,7 +37,8 @@ module Admin end def form_account_batch_params - params.require(:form_account_batch).permit(:action, account_ids: []) + params + .expect(form_account_batch: [:action, account_ids: []]) end def filter_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..ac4ee35271 100644 --- a/app/controllers/admin/invites_controller.rb +++ b/app/controllers/admin/invites_controller.rb @@ -32,14 +32,15 @@ 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 private def resource_params - params.require(:invite).permit(:max_uses, :expires_in) + params + .expect(invite: [:max_uses, :expires_in]) end def filtered_invites diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb index 1bd7ec8059..afabda1b88 100644 --- a/app/controllers/admin/ip_blocks_controller.rb +++ b/app/controllers/admin/ip_blocks_controller.rb @@ -44,7 +44,8 @@ module Admin private def resource_params - params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in) + params + .expect(ip_block: [:ip, :severity, :comment, :expires_in]) end def action_from_button @@ -52,7 +53,8 @@ module Admin end def form_ip_block_batch_params - params.require(:form_ip_block_batch).permit(ip_block_ids: []) + params + .expect(form_ip_block_batch: [ip_block_ids: []]) end end end diff --git a/app/controllers/admin/relays_controller.rb b/app/controllers/admin/relays_controller.rb index c893802159..9a796949de 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 @@ -53,7 +57,8 @@ module Admin end def resource_params - params.require(:relay).permit(:inbox_url) + params + .expect(relay: [:inbox_url]) end def warn_signatures_not_enabled! diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index 6b16c29fc7..10dbe846e4 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -47,10 +47,8 @@ module Admin end def resource_params - params.require(:report_note).permit( - :content, - :report_id - ) + params + .expect(report_note: [:content, :report_id]) end def set_report_note diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index bcfc11159c..2f9af8a6fc 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -61,7 +61,8 @@ module Admin end def resource_params - params.require(:user_role).permit(:name, :color, :highlighted, :position, permissions_as_keys: []) + params + .expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []]) end end end diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index b8def22ba3..289b6a98c3 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -53,7 +53,8 @@ module Admin end def resource_params - params.require(:rule).permit(:text, :hint, :priority) + params + .expect(rule: [:text, :hint, :priority]) end end end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 338a3638c4..2ae5ec8255 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -28,7 +28,8 @@ module Admin end def settings_params - params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS) + params + .expect(form_admin_settings: [*Form::AdminSettings::KEYS]) end end end diff --git a/app/controllers/admin/software_updates_controller.rb b/app/controllers/admin/software_updates_controller.rb index 52d8cb41e6..c9be97eb71 100644 --- a/app/controllers/admin/software_updates_controller.rb +++ b/app/controllers/admin/software_updates_controller.rb @@ -6,7 +6,7 @@ module Admin def index authorize :software_update, :index? - @software_updates = SoftwareUpdate.all.sort_by(&:gem_version) + @software_updates = SoftwareUpdate.by_version.filter(&:pending?) end private diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index e53b22dca3..aeadb35e7a 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 @@ -37,7 +39,8 @@ module Admin helper_method :batched_ordered_status_edits def admin_status_batch_action_params - params.require(:admin_status_batch_action).permit(status_ids: []) + params + .expect(admin_status_batch_action: [status_ids: []]) end def after_create_redirect_path diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 4759d15bc4..a7bfd64794 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -37,7 +37,8 @@ module Admin end def tag_params - params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable) + params + .expect(tag: [:name, :display_name, :trendable, :usable, :listable]) end def filtered_tags diff --git a/app/controllers/admin/terms_of_service/distributions_controller.rb b/app/controllers/admin/terms_of_service/distributions_controller.rb new file mode 100644 index 0000000000..c639b083dd --- /dev/null +++ b/app/controllers/admin/terms_of_service/distributions_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::DistributionsController < Admin::BaseController + before_action :set_terms_of_service + + def create + authorize @terms_of_service, :distribute? + @terms_of_service.touch(:notification_sent_at) + Admin::DistributeTermsOfServiceNotificationWorker.perform_async(@terms_of_service.id) + redirect_to admin_terms_of_service_index_path + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service/drafts_controller.rb b/app/controllers/admin/terms_of_service/drafts_controller.rb new file mode 100644 index 0000000000..0c67eb9df8 --- /dev/null +++ b/app/controllers/admin/terms_of_service/drafts_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::DraftsController < Admin::BaseController + before_action :set_terms_of_service + + def show + authorize :terms_of_service, :create? + end + + def update + authorize @terms_of_service, :update? + + @terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish' + + if @terms_of_service.update(resource_params) + log_action(:publish, @terms_of_service) if @terms_of_service.published? + redirect_to @terms_of_service.published? ? admin_terms_of_service_index_path : admin_terms_of_service_draft_path + else + render :show + end + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text, effective_date: 10.days.from_now) + end + + def current_terms_of_service + TermsOfService.live.first + end + + def resource_params + params + .expect(terms_of_service: [:text, :changelog, :effective_date]) + end +end diff --git a/app/controllers/admin/terms_of_service/generates_controller.rb b/app/controllers/admin/terms_of_service/generates_controller.rb new file mode 100644 index 0000000000..0edc87893e --- /dev/null +++ b/app/controllers/admin/terms_of_service/generates_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::GeneratesController < Admin::BaseController + before_action :set_instance_presenter + + def show + authorize :terms_of_service, :create? + + @generator = TermsOfService::Generator.new( + domain: @instance_presenter.domain, + admin_email: @instance_presenter.contact.email + ) + end + + def create + authorize :terms_of_service, :create? + + @generator = TermsOfService::Generator.new(resource_params) + + if @generator.valid? + TermsOfService.create!(text: @generator.render) + redirect_to admin_terms_of_service_draft_path + else + render :show + end + end + + private + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + + def resource_params + params + .expect(terms_of_service_generator: [*TermsOfService::Generator::VARIABLES]) + end +end diff --git a/app/controllers/admin/terms_of_service/histories_controller.rb b/app/controllers/admin/terms_of_service/histories_controller.rb new file mode 100644 index 0000000000..8f12341aea --- /dev/null +++ b/app/controllers/admin/terms_of_service/histories_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::HistoriesController < Admin::BaseController + def show + authorize :terms_of_service, :index? + @terms_of_service = TermsOfService.published.all + end +end diff --git a/app/controllers/admin/terms_of_service/previews_controller.rb b/app/controllers/admin/terms_of_service/previews_controller.rb new file mode 100644 index 0000000000..0a1a966751 --- /dev/null +++ b/app/controllers/admin/terms_of_service/previews_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::PreviewsController < Admin::BaseController + before_action :set_terms_of_service + + def show + authorize @terms_of_service, :distribute? + @user_count = @terms_of_service.scope_for_notification.count + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service/tests_controller.rb b/app/controllers/admin/terms_of_service/tests_controller.rb new file mode 100644 index 0000000000..e2483c1005 --- /dev/null +++ b/app/controllers/admin/terms_of_service/tests_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Admin::TermsOfService::TestsController < Admin::BaseController + before_action :set_terms_of_service + + def create + authorize @terms_of_service, :distribute? + UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later! + redirect_to admin_terms_of_service_preview_path(@terms_of_service) + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:terms_of_service_id]) + end +end diff --git a/app/controllers/admin/terms_of_service_controller.rb b/app/controllers/admin/terms_of_service_controller.rb new file mode 100644 index 0000000000..10aa5c66ca --- /dev/null +++ b/app/controllers/admin/terms_of_service_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Admin::TermsOfServiceController < Admin::BaseController + def index + authorize :terms_of_service, :index? + @terms_of_service = TermsOfService.published.first + end +end 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 5e4b4084f8..5a650d5d8c 100644 --- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -31,7 +31,8 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll end def trends_preview_card_provider_batch_params - params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: []) + params + .expect(trends_preview_card_provider_batch: [:action, preview_card_provider_ids: []]) end def action_from_button diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb index 83d68eba63..68aa73c992 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 @@ -31,7 +31,8 @@ class Admin::Trends::LinksController < Admin::BaseController end def trends_preview_card_batch_params - params.require(:trends_preview_card_batch).permit(:action, preview_card_ids: []) + params + .expect(trends_preview_card_batch: [:action, preview_card_ids: []]) end def action_from_button diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb index 3d8b53ea8a..873d777fe3 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 @@ -31,7 +31,8 @@ class Admin::Trends::StatusesController < Admin::BaseController end def trends_status_batch_params - params.require(:trends_status_batch).permit(:action, status_ids: []) + params + .expect(trends_status_batch: [:action, status_ids: []]) end def action_from_button diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb index fcd23fbf66..1ccd740686 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -31,7 +31,8 @@ class Admin::Trends::TagsController < Admin::BaseController end def trends_tag_batch_params - params.require(:trends_tag_batch).permit(:action, tag_ids: []) + params + .expect(trends_tag_batch: [:action, tag_ids: []]) end def action_from_button diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb index f5dfc643d4..e8b58de504 100644 --- a/app/controllers/admin/users/roles_controller.rb +++ b/app/controllers/admin/users/roles_controller.rb @@ -28,7 +28,8 @@ module Admin end def resource_params - params.require(:user).permit(:role_id) + params + .expect(user: [:role_id]) end end end diff --git a/app/controllers/admin/warning_presets_controller.rb b/app/controllers/admin/warning_presets_controller.rb index efbf65b119..dcf88294ee 100644 --- a/app/controllers/admin/warning_presets_controller.rb +++ b/app/controllers/admin/warning_presets_controller.rb @@ -52,7 +52,8 @@ module Admin end def warning_preset_params - params.require(:account_warning_preset).permit(:title, :text) + params + .expect(account_warning_preset: [:title, :text]) end end end diff --git a/app/controllers/admin/webhooks_controller.rb b/app/controllers/admin/webhooks_controller.rb index f1aad7c4b5..31db369637 100644 --- a/app/controllers/admin/webhooks_controller.rb +++ b/app/controllers/admin/webhooks_controller.rb @@ -74,7 +74,8 @@ module Admin end def resource_params - params.require(:webhook).permit(:url, :template, events: []) + params + .expect(webhook: [:url, :template, events: []]) end end end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index a378425183..b90036a5cd 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController @account = current_account UpdateAccountService.new.call(@account, account_params, raise_error: true) current_user.update(user_params) if user_params - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer rescue ActiveRecord::RecordInvalid => e render json: ValidationErrorFormatter.new(e).as_json, status: 422 @@ -33,6 +33,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController :discoverable, :hide_collections, :indexable, + attribution_domains: [], fields_attributes: [:name, :value] ) 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 28acaeea05..f7d3de7f94 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -106,8 +106,8 @@ class Api::V1::AccountsController < Api::BaseController render json: { error: I18n.t('accounts.self_follow_error') }, status: 403 if current_user.account.id == @account.id end - def relationships(**options) - AccountRelationshipsPresenter.new([@account], current_user.account_id, **options) + 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/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb index 7ec94312f4..bf96fbaaa8 100644 --- a/app/controllers/api/v1/instances/domain_blocks_controller.rb +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -31,7 +31,7 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr end def show_domain_blocks_to_user? - Setting.show_domain_blocks == 'users' && user_signed_in? + Setting.show_domain_blocks == 'users' && user_signed_in? && current_user.functional_or_moved? end def set_domain_blocks @@ -47,6 +47,6 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr end def show_rationale_for_user? - Setting.show_domain_blocks_rationale == 'users' && user_signed_in? + Setting.show_domain_blocks_rationale == 'users' && user_signed_in? && current_user.functional_or_moved? end end diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb new file mode 100644 index 0000000000..0a861dd7bb --- /dev/null +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController + before_action :set_terms_of_service + + def show + cache_even_if_authenticated! + render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer + end + + private + + def set_terms_of_service + @terms_of_service = begin + if params[:date].present? + TermsOfService.published.find_by!(effective_date: params[:date]) + else + TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet + end + end + end +end 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/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 5ea26d55bd..c427e055ea 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -3,8 +3,8 @@ class Api::V1::MediaController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:media' } before_action :require_user! - before_action :set_media_attachment, except: [:create] - before_action :check_processing, except: [:create] + before_action :set_media_attachment, except: [:create, :destroy] + before_action :check_processing, except: [:create, :destroy] def show render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment @@ -25,6 +25,15 @@ class Api::V1::MediaController < Api::BaseController render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment end + def destroy + @media_attachment = current_account.media_attachments.find(params[:id]) + + return render json: in_usage_error, status: 422 unless @media_attachment.status_id.nil? + + @media_attachment.destroy + render_empty + end + private def status_code_for_media_attachment @@ -54,4 +63,8 @@ class Api::V1::MediaController < Api::BaseController def processing_error { error: 'Error processing thumbnail for uploaded media' } end + + def in_usage_error + { error: 'Media attachment is currently used by a status' } + end end diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index ad1b82cb52..2833687a38 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Polls::VotesController < Api::BaseController private def set_poll - @poll = Poll.attached.find(params[:poll_id]) + @poll = Poll.find(params[:poll_id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index ffc70a8496..b4c25476e8 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -15,7 +15,7 @@ class Api::V1::PollsController < Api::BaseController private def set_poll - @poll = Poll.attached.find(params[:id]) + @poll = Poll.find(params[:id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/api/v1/profile/avatars_controller.rb b/app/controllers/api/v1/profile/avatars_controller.rb index bc4d01a597..e6c954ed63 100644 --- a/app/controllers/api/v1/profile/avatars_controller.rb +++ b/app/controllers/api/v1/profile/avatars_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::AvatarsController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/api/v1/profile/headers_controller.rb b/app/controllers/api/v1/profile/headers_controller.rb index 9f4daa2f77..4472a01b05 100644 --- a/app/controllers/api/v1/profile/headers_controller.rb +++ b/app/controllers/api/v1/profile/headers_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::HeadersController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { header: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index e1ad89ee3e..f2c52f2846 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -21,6 +21,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController endpoint: subscription_params[:endpoint], key_p256dh: subscription_params[:keys][:p256dh], key_auth: subscription_params[:keys][:auth], + standard: subscription_params[:standard] || false, data: data_params, user_id: current_user.id, access_token_id: doorkeeper_token.id @@ -55,12 +56,12 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController end def subscription_params - params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) + params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]]) end def data_params return {} if params[:data].blank? - params.require(:data).permit(:policy, alerts: Notification::TYPES) + params.expect(data: [:policy, alerts: Notification::TYPES]) end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 19cc71ae58..d3b0e89e97 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -58,6 +58,8 @@ class Api::V1::StatusesController < Api::BaseController statuses = [@status] + @context.ancestors + @context.descendants render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies? end def create @@ -111,7 +113,7 @@ class Api::V1::StatusesController < Api::BaseController @status.account.statuses_count = @status.account.statuses_count - 1 json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true - RemovalWorker.perform_async(@status.id, { 'redraft' => true }) + RemovalWorker.perform_async(@status.id, { 'redraft' => !truthy_param?(:delete_media) }) render json: json 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/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb index c070c0e5e7..848c361cfc 100644 --- a/app/controllers/api/v2/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -46,7 +46,7 @@ class Api::V2::NotificationsController < Api::BaseController end def show - @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:group_key]) + @notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take! presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification])) render json: presenter, serializer: REST::DedupNotificationGroupSerializer end @@ -57,7 +57,7 @@ class Api::V2::NotificationsController < Api::BaseController end def dismiss - current_account.notifications.where(group_key: params[:group_key]).destroy_all + current_account.notifications.by_group_key(params[:group_key]).destroy_all render_empty end @@ -80,10 +80,31 @@ class Api::V2::NotificationsController < Api::BaseController return [] if @notifications.empty? MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do - NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types]) + pagination_range = (@notifications.last.id)..@notifications.first.id + + # If the page is incomplete, we know we are on the last page + if incomplete_page? + if paginating_up? + pagination_range = @notifications.last.id...(params[:max_id]&.to_i) + else + range_start = params[:since_id]&.to_i + range_start += 1 unless range_start.nil? + pagination_range = range_start..(@notifications.first.id) + end + end + + NotificationGroup.from_notifications(@notifications, pagination_range: pagination_range, grouped_types: params[:grouped_types]) end end + def incomplete_page? + @notifications.size < limit_param(DEFAULT_NOTIFICATIONS_LIMIT) + end + + def paginating_up? + params[:min_id].present? + end + def browserable_account_notifications current_account.notifications.without_suspended.browserable( types: Array(browserable_params[:types]), diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index f515961427..2711071b4a 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -66,7 +66,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def subscription_params - @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) + @subscription_params ||= params.expect(subscription: [:standard, :endpoint, keys: [:auth, :p256dh]]) end def web_push_subscription_params @@ -76,11 +76,12 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController endpoint: subscription_params[:endpoint], key_auth: subscription_params[:keys][:auth], key_p256dh: subscription_params[:keys][:p256dh], + standard: subscription_params[:standard] || false, user_id: active_session.user_id, } end def data_params - @data_params ||= params.require(:data).permit(:policy, alerts: Notification::TYPES) + @data_params ||= params.expect(data: [:policy, alerts: Notification::TYPES]) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d493bd43bf..1b071e8655 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 @@ -71,7 +70,13 @@ class ApplicationController < ActionController::Base end def require_functional! - redirect_to edit_user_registration_path unless current_user.functional? + return if current_user.functional? + + if current_user.confirmed? + redirect_to edit_user_registration_path + else + redirect_to auth_setup_path + end end def skip_csrf_meta_tags? @@ -158,10 +163,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/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 4d94c80158..6e34b6b627 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -12,7 +12,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_sessions, only: [:edit, :update] before_action :set_strikes, only: [:edit, :update] before_action :require_not_suspended!, only: [:update] - before_action :set_cache_headers, only: [:edit, :update] before_action :set_rules, only: :new before_action :require_rules_acceptance!, only: :new before_action :set_registration_form_time, only: :new @@ -139,7 +138,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController set_locale { render :rules } end - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) + def is_flashing_format? # rubocop:disable Naming/PredicateName + if params[:action] == 'create' + false # Disable flash messages for sign-up + else + super + end end end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index ecac4c5ba8..250573fc7d 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -73,7 +73,7 @@ class Auth::SessionsController < Devise::SessionsController end def user_params - params.require(:user).permit(:email, :password, :otp_attempt, credential: {}) + params.expect(user: [:email, :password, :otp_attempt, credential: {}]) end def after_sign_in_path_for(resource) diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb index ad872dc607..5e7b14646a 100644 --- a/app/controllers/auth/setup_controller.rb +++ b/app/controllers/auth/setup_controller.rb @@ -18,7 +18,7 @@ class Auth::SetupController < ApplicationController if @user.update(user_params) @user.resend_confirmation_instructions unless @user.confirmed? - redirect_to auth_setup_path, notice: I18n.t('auth.setup.new_confirmation_instructions_sent') + redirect_to auth_setup_path, notice: t('auth.setup.new_confirmation_instructions_sent') else render :show end @@ -35,6 +35,6 @@ class Auth::SetupController < ApplicationController end def user_params - params.require(:user).permit(:email) + params.expect(user: [:email]) end end diff --git a/app/controllers/concerns/admin/export_controller_concern.rb b/app/controllers/concerns/admin/export_controller_concern.rb index 6228ae67fe..ce03b2a24a 100644 --- a/app/controllers/concerns/admin/export_controller_concern.rb +++ b/app/controllers/concerns/admin/export_controller_concern.rb @@ -24,6 +24,6 @@ module Admin::ExportControllerConcern end def import_params - params.require(:admin_import).permit(:data) + params.expect(admin_import: [:data]) end end 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/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index c8d1a0bef7..7fbc469bdf 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -58,6 +58,6 @@ module ChallengableConcern end def challenge_params - params.require(:form_challenge).permit(:current_password, :return_to) + params.expect(form_challenge: [:current_password, :return_to]) end end diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index ede299d5a4..14742e3b5c 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -25,7 +25,7 @@ module Localized end def available_locale_or_nil(locale_name) - locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym) + locale_name.to_sym if locale_name.respond_to?(:to_sym) && I18n.available_locales.include?(locale_name.to_sym) end def content_locale diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4ae63632c0..5f7ef8dd63 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -117,7 +117,7 @@ module SignatureVerification def verify_signature_strength! raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') - raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') + raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest') raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') end @@ -155,14 +155,14 @@ module SignatureVerification def build_signed_string(include_query_string: true) signed_headers.map do |signed_header| case signed_header - when Request::REQUEST_TARGET + when HttpSignatureDraft::REQUEST_TARGET if include_query_string - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" else # Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header. # Therefore, temporarily support such incorrect signatures for compatibility. # TODO: remove eventually some time after release of the fixed version - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" end when '(created)' raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index 9485ecda49..ec2256aa9c 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -7,7 +7,7 @@ module WebAppControllerConcern vary_by 'Accept, Accept-Language, Cookie' before_action :redirect_unauthenticated_to_permalinks! - before_action :set_app_body_class + before_action :set_referer_header content_security_policy do |p| policy = ContentSecurityPolicy.new @@ -24,10 +24,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? @@ -46,4 +42,10 @@ module WebAppControllerConcern end end end + + protected + + def set_referer_header + response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'strict-origin-when-cross-origin' : 'same-origin') + end end diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index eb6417698a..5b98914114 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController - before_action :set_user_roles - def show - expires_in 3.minutes, public: true + expires_in 1.month, public: true render content_type: 'text/css' end @@ -14,8 +12,4 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/Appli Setting.custom_css end helper_method :custom_css_styles - - def set_user_roles - @user_roles = UserRole.providing_styles - end end diff --git a/app/controllers/disputes/appeals_controller.rb b/app/controllers/disputes/appeals_controller.rb index 98b58d2117..797f31cf78 100644 --- a/app/controllers/disputes/appeals_controller.rb +++ b/app/controllers/disputes/appeals_controller.rb @@ -21,6 +21,6 @@ class Disputes::AppealsController < Disputes::BaseController end def appeal_params - params.require(:appeal).permit(:text) + params.expect(appeal: [:text]) end end diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb index dd24a1b740..07677fd3f3 100644 --- a/app/controllers/disputes/base_controller.rb +++ b/app/controllers/disputes/base_controller.rb @@ -8,11 +8,4 @@ class Disputes::BaseController < ApplicationController skip_before_action :require_functional! before_action :authenticate_user! - before_action :set_cache_headers - - private - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb index 7ada13f680..d85b017aaa 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -6,7 +6,6 @@ class Filters::StatusesController < ApplicationController before_action :authenticate_user! before_action :set_filter before_action :set_status_filters - before_action :set_cache_headers PER_PAGE = 20 @@ -34,14 +33,10 @@ class Filters::StatusesController < ApplicationController end def status_filter_batch_action_params - params.require(:form_status_filter_batch_action).permit(status_filter_ids: []) + params.expect(form_status_filter_batch_action: [status_filter_ids: []]) end def action_from_button 'remove' if params[:remove] end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 8c4e867e93..769aea2afe 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -5,7 +5,6 @@ class FiltersController < ApplicationController before_action :authenticate_user! before_action :set_filter, only: [:edit, :update, :destroy] - before_action :set_cache_headers def index @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase) @@ -48,10 +47,6 @@ class FiltersController < ApplicationController end def resource_params - params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) - end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) + params.expect(custom_filter: [:title, :expires_in, :filter_action, context: [], keywords_attributes: [[:id, :keyword, :whole_word, :_destroy]]]) end end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 5effd9495e..f4c7b37088 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -46,7 +46,7 @@ class FollowerAccountsController < ApplicationController end def page_url(page) - account_followers_url(@account, page: page) unless page.nil? + ActivityPub::TagManager.instance.followers_uri_for(@account, page: page) unless page.nil? end def next_page_url diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 070852695e..fc65333ac4 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,7 +6,6 @@ class InvitesController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_cache_headers def index authorize :invite, :create? @@ -43,10 +42,6 @@ class InvitesController < ApplicationController end def resource_params - params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment) - end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) + params.expect(invite: [:max_uses, :expires_in, :autofollow, :comment]) end end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 66e774425d..deafedeaef 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -5,7 +5,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController before_action :store_current_location before_action :authenticate_resource_owner! - before_action :set_cache_headers content_security_policy do |p| p.form_action(false) @@ -32,8 +31,4 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController def truthy_param?(key) ActiveModel::Type::Boolean.new.cast(params[key]) end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 267409a9ce..8b11a519ea 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -6,7 +6,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :store_current_location before_action :authenticate_resource_owner! before_action :require_not_suspended!, only: :destroy - before_action :set_cache_headers before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json } @@ -30,17 +29,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio forbidden if current_account.unavailable? end - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end - def set_last_used_at_by_app - @last_used_at_by_app = Doorkeeper::AccessToken - .select('DISTINCT ON (application_id) application_id, last_used_at') - .where(resource_owner_id: current_resource_owner.id) - .where.not(last_used_at: nil) - .order(application_id: :desc, last_used_at: :desc) - .pluck(:application_id, :last_used_at) - .to_h + @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/relationships_controller.rb b/app/controllers/relationships_controller.rb index d351afcfb7..7e793fc734 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -6,7 +6,6 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show before_action :set_relationships, only: :show - before_action :set_cache_headers helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -36,7 +35,7 @@ class RelationshipsController < ApplicationController end def form_account_batch_params - params.require(:form_account_batch).permit(:action, account_ids: []) + params.expect(form_account_batch: [:action, account_ids: []]) end def following_relationship? @@ -66,8 +65,4 @@ class RelationshipsController < ApplicationController 'remove_domains_from_followers' end end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb index a421b8ede3..c21d43eeb3 100644 --- a/app/controllers/settings/aliases_controller.rb +++ b/app/controllers/settings/aliases_controller.rb @@ -30,7 +30,7 @@ class Settings::AliasesController < Settings::BaseController private def resource_params - params.require(:account_alias).permit(:acct) + params.expect(account_alias: [:acct]) end def set_alias diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index d6573f9b49..8e39741f89 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -2,7 +2,6 @@ class Settings::ApplicationsController < Settings::BaseController before_action :set_application, only: [:show, :update, :destroy, :regenerate] - before_action :prepare_scopes, only: [:create, :update] def index @applications = current_user.applications.order(id: :desc).page(params[:page]) @@ -60,16 +59,6 @@ class Settings::ApplicationsController < Settings::BaseController end def application_params - params.require(:doorkeeper_application).permit( - :name, - :redirect_uri, - :scopes, - :website - ) - end - - def prepare_scopes - scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) - params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array + params.expect(doorkeeper_application: [:name, :redirect_uri, :website, scopes: []]) end end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 188334ac23..7f2279aa8f 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -4,14 +4,9 @@ class Settings::BaseController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_cache_headers private - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end - def require_not_suspended! forbidden if current_account.unavailable? end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index 16c201b6b3..815d95ad83 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -21,7 +21,7 @@ class Settings::DeletesController < Settings::BaseController private def resource_params - params.require(:form_delete_confirmation).permit(:password, :username) + params.expect(form_delete_confirmation: [:password, :username]) end def require_not_suspended! diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index 90c112e219..0f352e1913 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,10 +40,10 @@ 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 - params.require(:featured_tag).permit(:name) + params.expect(featured_tag: [:name]) end end diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index 569aa07c53..be1699315f 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 @@ -88,7 +90,7 @@ class Settings::ImportsController < Settings::BaseController private def import_params - params.require(:form_import).permit(:data, :type, :mode) + params.expect(form_import: [:data, :type, :mode]) end def set_bulk_import @@ -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/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb index 6d469f3842..d850e05e94 100644 --- a/app/controllers/settings/migration/redirects_controller.rb +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -33,6 +33,6 @@ class Settings::Migration::RedirectsController < Settings::BaseController private def resource_params - params.require(:form_redirect).permit(:acct, :current_password, :current_username) + params.expect(form_redirect: [:acct, :current_password, :current_username]) end end diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb index 62603aba81..92e3611fd9 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -27,7 +27,7 @@ class Settings::MigrationsController < Settings::BaseController private def resource_params - params.require(:account_migration).permit(:acct, :current_password, :current_username) + params.expect(account_migration: [:acct, :current_password, :current_username]) end def set_migrations diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb index 58a4325307..7e61e6d580 100644 --- a/app/controllers/settings/pictures_controller.rb +++ b/app/controllers/settings/pictures_controller.rb @@ -8,7 +8,7 @@ module Settings def destroy if valid_picture? if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' }) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303 else redirect_to settings_profile_path diff --git a/app/controllers/settings/preferences/base_controller.rb b/app/controllers/settings/preferences/base_controller.rb index c1f8b49898..d6d42b0340 100644 --- a/app/controllers/settings/preferences/base_controller.rb +++ b/app/controllers/settings/preferences/base_controller.rb @@ -19,6 +19,6 @@ class Settings::Preferences::BaseController < Settings::BaseController end def user_params - params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys) + params.expect(user: [:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys]) end end diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb index 1102c89fad..96efa03ccf 100644 --- a/app/controllers/settings/privacy_controller.rb +++ b/app/controllers/settings/privacy_controller.rb @@ -8,7 +8,7 @@ class Settings::PrivacyController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params.except(:settings)) current_user.update!(settings_attributes: account_params[:settings]) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg') else render :show @@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController private def account_params - params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys) + params.expect(account: [:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys]) end def set_account diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 8ae69b7fe0..efd8eb1440 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -9,7 +9,7 @@ class Settings::ProfilesController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else @account.build_fields @@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :bot, fields_attributes: [:name, :value]) + params.expect(account: [:display_name, :note, :avatar, :header, :bot, fields_attributes: [[:name, :value]]]) end def set_account diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index 1a0afe58b0..eae990e79b 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -38,7 +38,7 @@ module Settings private def confirmation_params - params.require(:form_two_factor_confirmation).permit(:otp_attempt) + params.expect(form_two_factor_confirmation: [:otp_attempt]) end def prepare_two_factor_form diff --git a/app/controllers/settings/verifications_controller.rb b/app/controllers/settings/verifications_controller.rb index 4e0663253c..4b949ca72d 100644 --- a/app/controllers/settings/verifications_controller.rb +++ b/app/controllers/settings/verifications_controller.rb @@ -8,7 +8,7 @@ class Settings::VerificationsController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') else render :show @@ -18,7 +18,9 @@ class Settings::VerificationsController < Settings::BaseController private def account_params - params.require(:account).permit(:attribution_domains_as_text) + params.expect(account: [:attribution_domains]).tap do |params| + params[:attribution_domains] = params[:attribution_domains].split if params[:attribution_domains] + end end def set_account diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb index 965753a26f..817abebf62 100644 --- a/app/controllers/severed_relationships_controller.rb +++ b/app/controllers/severed_relationships_controller.rb @@ -4,7 +4,6 @@ class SeveredRelationshipsController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_cache_headers before_action :set_event, only: [:following, :followers] @@ -49,8 +48,4 @@ class SeveredRelationshipsController < ApplicationController def acct(account) account.local? ? account.local_username_and_domain : account.acct end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb index e517bf3ae8..f4f49031a0 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -5,7 +5,6 @@ class StatusesCleanupController < ApplicationController before_action :authenticate_user! before_action :set_policy - before_action :set_cache_headers def show; end @@ -15,8 +14,6 @@ class StatusesCleanupController < ApplicationController else render :show end - rescue ActionController::ParameterMissing - # Do nothing end def require_functional! @@ -30,10 +27,6 @@ class StatusesCleanupController < ApplicationController end def resource_params - params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs) - end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) + params.expect(account_statuses_cleanup_policy: [:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs]) end end diff --git a/app/controllers/terms_of_service_controller.rb b/app/controllers/terms_of_service_controller.rb new file mode 100644 index 0000000000..672fb07915 --- /dev/null +++ b/app/controllers/terms_of_service_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TermsOfServiceController < ApplicationController + include WebAppControllerConcern + + skip_before_action :require_functional! + + def show + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? + 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 51e28d8b4e..859f924687 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -33,6 +33,8 @@ module Admin::ActionLogsHelper else I18n.t('admin.action_logs.deleted_account') end + when 'Relay' + link_to log.human_identifier, admin_relays_path end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e36de19255..5a5ee05532 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -79,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, ' - ' ) @@ -143,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(' ') @@ -237,6 +238,14 @@ module ApplicationHelper I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people end + def app_store_url_ios + 'https://apps.apple.com/app/mastodon-for-iphone-and-ipad/id1571998974' + end + + def app_store_url_android + 'https://play.google.com/store/apps/details?id=org.joinmastodon.android' + end + private def storage_host_var diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 2ef7d362d8..e827834975 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -27,7 +27,14 @@ 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) @@ -39,7 +46,14 @@ module FormattingHelper 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) @@ -50,6 +64,10 @@ module FormattingHelper end end + def markdown(text) + Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true).render(text).html_safe # rubocop:disable Rails/OutputSafety + end + private def wrapped_status_content_format(status) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/json_ld_helper.rb similarity index 84% rename from app/helpers/jsonld_helper.rb rename to app/helpers/json_ld_helper.rb index ba096427cf..65daaa5302 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -155,24 +155,49 @@ module JsonLdHelper end end - def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {}) + # Fetch the resource given by uri. + # @param uri [String] + # @param id_is_known [Boolean] + # @param on_behalf_of [nil, Account] + # @param raise_on_error [Symbol<:all, :temporary, :none>] See {#fetch_resource_without_id_validation} for possible values + def fetch_resource(uri, id_is_known, on_behalf_of = nil, raise_on_error: :none, request_options: {}) unless id_is_known - json = fetch_resource_without_id_validation(uri, on_behalf_of) + json = fetch_resource_without_id_validation(uri, on_behalf_of, raise_on_error: raise_on_error) return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id']) uri = json['id'] end - json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options) + json = fetch_resource_without_id_validation(uri, on_behalf_of, raise_on_error: raise_on_error, request_options: request_options) json.present? && json['id'] == uri ? json : nil end - def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {}) + # Fetch the resource given by uri + # + # If an error is raised, it contains the response and can be captured for handling like + # + # begin + # fetch_resource_without_id_validation(uri, nil, true) + # rescue Mastodon::UnexpectedResponseError => e + # e.response + # end + # + # @param uri [String] + # @param on_behalf_of [nil, Account] + # @param raise_on_error [Symbol<:all, :temporary, :none>] + # - +:all+ - raise if response code is not in the 2xx range + # - +:temporary+ - raise if the response code is not an "unsalvageable error" like a 404 + # (see {#response_error_unsalvageable} ) + # - +:none+ - do not raise, return +nil+ + def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_error: :none, request_options: {}) on_behalf_of ||= Account.representative build_request(uri, on_behalf_of, options: request_options).perform do |response| - raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response if !response_successful?(response) && ( + raise_on_error == :all || + (!response_error_unsalvageable?(response) && raise_on_error == :temporary) + ) body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response) end 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/statuses_helper.rb b/app/helpers/statuses_helper.rb index 9bbb03fd82..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', @@ -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/theme_helper.rb b/app/helpers/theme_helper.rb index fab899a533..cda380b3bc 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -23,8 +23,33 @@ module ThemeHelper end end + def custom_stylesheet + if active_custom_stylesheet.present? + stylesheet_link_tag( + custom_css_path(active_custom_stylesheet), + host: root_url, + media: :all, + skip_pipeline: true + ) + end + end + private + def active_custom_stylesheet + if cached_custom_css_digest.present? + [:custom, cached_custom_css_digest.to_s.first(8)] + .compact_blank + .join('-') + end + end + + def cached_custom_css_digest + Rails.cache.fetch(:setting_digest_custom_css) do + Setting.custom_css&.then { |content| Digest::SHA256.hexdigest(content) } + end + end + def theme_color_for(theme) theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark] end diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx index f8c824d287..6c091e4d07 100644 --- a/app/javascript/entrypoints/embed.tsx +++ b/app/javascript/entrypoints/embed.tsx @@ -1,7 +1,7 @@ import './public-path'; import { createRoot } from 'react-dom/client'; -import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal'; +import { afterInitialRender } from 'mastodon/hooks/useRenderSignal'; import { start } from '../mastodon/common'; import { Status } from '../mastodon/features/standalone/status'; @@ -60,6 +60,10 @@ window.addEventListener('message', (e) => { const data = e.data; + // Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if + // embedded without parent Javascript support + document.body.style.overflow = 'hidden'; + // We use a timeout to allow for the React page to render before calculating the height afterInitialRender(() => { window.parent.postMessage( diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index c1e8418014..0560e76628 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -119,7 +119,11 @@ function loaded() { formattedContent = dateFormat.format(datetime); } - content.title = formattedContent; + const timeGiven = content.dateTime.includes('T'); + content.title = timeGiven + ? dateTimeFormat.format(datetime) + : dateFormat.format(datetime); + content.textContent = formattedContent; }); @@ -230,62 +234,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( @@ -439,6 +387,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..df2a0226f8 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..e37f98aab2 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..9d4e2177c5 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..9fe6281af0 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..6c6325b9f1 Binary files /dev/null and b/app/javascript/images/archetypes/replier.png differ diff --git a/app/javascript/images/reticle.png b/app/javascript/images/reticle.png deleted file mode 100644 index a724ac0bcd..0000000000 Binary files a/app/javascript/images/reticle.png and /dev/null differ diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 3d0e8b8c90..d821381ce0 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -142,6 +142,13 @@ export function fetchAccountFail(id, error) { }; } +/** + * @param {string} id + * @param {Object} options + * @param {boolean} [options.reblogs] + * @param {boolean} [options.notify] + * @returns {function(): void} + */ export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { const alreadyFollowing = getState().getIn(['relationships', id, 'following']); 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/compose.js b/app/javascript/mastodon/actions/compose.js index aa1c6de20e..d70834cec6 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -414,7 +414,7 @@ export function initMediaEditModal(id) { dispatch(openModal({ modalType: 'FOCAL_POINT', - modalProps: { id }, + modalProps: { mediaId: id }, })); }; } diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts new file mode 100644 index 0000000000..97f0d68c51 --- /dev/null +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -0,0 +1,70 @@ +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; + +import { apiUpdateMedia } from 'mastodon/api/compose'; +import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; +import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { + unattached?: boolean; +}; + +const simulateModifiedApiResponse = ( + media: MediaAttachment, + params: { description?: string; focus?: string }, +): SimulatedMediaAttachmentJSON => { + const [x, y] = (params.focus ?? '').split(','); + + const data = { + ...media.toJS(), + ...params, + meta: { + focus: { + x: parseFloat(x ?? '0'), + y: parseFloat(y ?? '0'), + }, + }, + } as unknown as SimulatedMediaAttachmentJSON; + + return data; +}; + +export const changeUploadCompose = createDataLoadingThunk( + 'compose/changeUpload', + async ( + { + id, + ...params + }: { + id: string; + description: string; + focus: string; + }, + { getState }, + ) => { + const media = ( + (getState().compose as ImmutableMap).get( + 'media_attachments', + ) as ImmutableList + ).find((item) => item.get('id') === id); + + // Editing already-attached media is deferred to editing the post itself. + // For simplicity's sake, fake an API reply. + if (media && !media.get('unattached')) { + return new Promise((resolve) => { + resolve(simulateModifiedApiResponse(media, params)); + }); + } + + return apiUpdateMedia(id, params); + }, + (media: SimulatedMediaAttachmentJSON) => { + return { + media, + attached: typeof media.unattached !== 'undefined' && !media.unattached, + }; + }, + { + useLoadingBar: false, + }, +); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 516a7a7973..047cf11910 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,10 +1,12 @@ +import { createPollFromServerJSON } from 'mastodon/models/poll'; + import { importAccounts } from '../accounts_typed'; -import { normalizeStatus, normalizePoll } from './normalizer'; +import { normalizeStatus } from './normalizer'; +import { importPolls } from './polls'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; -export const POLLS_IMPORT = 'POLLS_IMPORT'; export const FILTERS_IMPORT = 'FILTERS_IMPORT'; function pushUnique(array, object) { @@ -25,10 +27,6 @@ export function importFilters(filters) { return { type: FILTERS_IMPORT, filters }; } -export function importPolls(polls) { - return { type: POLLS_IMPORT, polls }; -} - export function importFetchedAccount(account) { return importFetchedAccounts([account]); } @@ -73,7 +71,7 @@ export function importFetchedStatuses(statuses) { } if (status.poll?.id) { - pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); + pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id))); } if (status.card) { @@ -83,15 +81,9 @@ export function importFetchedStatuses(statuses) { statuses.forEach(processStatus); - dispatch(importPolls(polls)); + dispatch(importPolls({ polls })); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); dispatch(importFilters(filters)); }; } - -export function importFetchedPoll(poll) { - return (dispatch, getState) => { - dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))])); - }; -} diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index c09a3f442c..c2918ef8d5 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -1,15 +1,12 @@ import escapeTextContentForBrowser from 'escape-html'; +import { makeEmojiMap } from 'mastodon/models/custom_emoji'; + import emojify from '../../features/emoji/emoji'; import { expandSpoilers } from '../../initial_state'; const domParser = new DOMParser(); -const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji; - return obj; -}, {}); - export function searchTextFromRawStatus (status) { const spoilerText = status.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -112,38 +109,6 @@ export function normalizeStatusTranslation(translation, status) { return normalTranslation; } -export function normalizePoll(poll, normalOldPoll) { - const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(poll.emojis); - - normalPoll.options = poll.options.map((option, index) => { - const normalOption = { - ...option, - voted: poll.own_votes && poll.own_votes.includes(index), - titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), - }; - - if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) { - normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']); - } - - return normalOption; - }); - - return normalPoll; -} - -export function normalizePollOptionTranslation(translation, poll) { - const emojiMap = makeEmojiMap(poll.get('emojis').toJS()); - - const normalTranslation = { - ...translation, - titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap), - }; - - return normalTranslation; -} - export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; const emojiMap = makeEmojiMap(normalAnnouncement.emojis); diff --git a/app/javascript/mastodon/actions/importer/polls.ts b/app/javascript/mastodon/actions/importer/polls.ts new file mode 100644 index 0000000000..5bbe7d57d6 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/polls.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Poll } from 'mastodon/models/poll'; + +export const importPolls = createAction<{ polls: Poll[] }>( + 'poll/importMultiple', +); 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/modal.ts b/app/javascript/mastodon/actions/modal.ts index ab03e46765..49af176a11 100644 --- a/app/javascript/mastodon/actions/modal.ts +++ b/app/javascript/mastodon/actions/modal.ts @@ -9,6 +9,7 @@ export type ModalType = keyof typeof MODAL_COMPONENTS; interface OpenModalPayload { modalType: ModalType; modalProps: ModalProps; + previousModalProps?: ModalProps; } export const openModal = createAction('MODAL_OPEN'); diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index a3c8095ac4..4386325481 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -141,6 +141,9 @@ export const pollRecentNotifications = createDataLoadingThunk( return { notifications }; }, + { + useLoadingBar: false, + }, ); export const processNewNotificationForGroups = createAppAsyncThunk( @@ -152,7 +155,7 @@ export const processNewNotificationForGroups = createAppAsyncThunk( const showInColumn = activeFilter === 'all' - ? notificationShows[notification.type] + ? notificationShows[notification.type] !== false : activeFilter === notification.type; if (!showInColumn) return; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 4c6e27cd5f..2499b8da1d 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -1,69 +1,31 @@ import { IntlMessageFormat } from 'intl-messageformat'; import { defineMessages } from 'react-intl'; -import { List as ImmutableList } from 'immutable'; - -import { compareId } from 'mastodon/compare_id'; -import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; - -import api, { getLinks } from '../api'; import { unescapeHTML } from '../utils/html'; import { requestNotificationPermission } from '../utils/notifications'; import { fetchFollowRequests } from './accounts'; import { importFetchedAccount, - importFetchedAccounts, - importFetchedStatus, - importFetchedStatuses, } from './importer'; import { submitMarkers } from './markers'; import { notificationsUpdate } from "./notifications_typed"; import { register as registerPushNotifications } from './push_notifications'; -import { saveSettings } from './settings'; export * from "./notifications_typed"; -export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; - -export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; -export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; -export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; - export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; -export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; - -export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; -export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; - -export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; - export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; -export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST'; -export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS'; -export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL'; - -export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST'; -export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS'; -export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL'; - defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, }); -export const loadPending = () => ({ - type: NOTIFICATIONS_LOAD_PENDING, -}); - export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { - const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); - const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type; const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); @@ -85,25 +47,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch(submitMarkers()); - if (showInColumn) { - dispatch(importFetchedAccount(notification.account)); - - if (notification.status) { - dispatch(importFetchedStatus(notification.status)); - } - - if (notification.report) { - dispatch(importFetchedAccount(notification.report.target_account)); - } - - - dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered})); - } else if (playSound && !filtered) { - dispatch({ - type: NOTIFICATIONS_UPDATE_NOOP, - meta: { sound: 'boop' }, - }); - } + // `notificationsUpdate` is still used in `user_lists` and `relationships` reducers + dispatch(importFetchedAccount(notification.account)); + dispatch(notificationsUpdate({ notification, playSound: playSound && !filtered})); // Desktop notifications if (typeof window.Notification !== 'undefined' && showAlert && !filtered) { @@ -120,141 +66,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) { }; } -const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); - -const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList([ - 'follow', - 'follow_request', - 'favourite', - 'reblog', - 'mention', - 'poll', - 'status', - 'update', - 'admin.sign_up', - 'admin.report', - ]); - - return allTypes.filterNot(item => item === filter).toJS(); -}; - const noOp = () => {}; -let expandNotificationsController = new AbortController(); - -export function expandNotifications({ maxId = undefined, forceLoad = false }) { - return async (dispatch, getState) => { - const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); - const notifications = getState().get('notifications'); - const isLoadingMore = !!maxId; - - if (notifications.get('isLoading')) { - if (forceLoad) { - expandNotificationsController.abort(); - expandNotificationsController = new AbortController(); - } else { - return; - } - } - - const params = { - max_id: maxId, - exclude_types: activeFilter === 'all' - ? excludeTypesFromSettings(getState()) - : excludeTypesFromFilter(activeFilter), - }; - - if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { - const a = notifications.getIn(['pendingItems', 0, 'id']); - const b = notifications.getIn(['items', 0, 'id']); - - if (a && b && compareId(a, b) > 0) { - params.since_id = a; - } else { - params.since_id = b || a; - } - } - - const isLoadingRecent = !!params.since_id; - - dispatch(expandNotificationsRequest(isLoadingMore)); - - try { - const response = await api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }); - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data.map(item => item.account))); - dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); - - dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); - dispatch(submitMarkers()); - } catch(error) { - dispatch(expandNotificationsFail(error, isLoadingMore)); - } - }; -} - -export function expandNotificationsRequest(isLoadingMore) { - return { - type: NOTIFICATIONS_EXPAND_REQUEST, - skipLoading: !isLoadingMore, - }; -} - -export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) { - return { - type: NOTIFICATIONS_EXPAND_SUCCESS, - notifications, - next, - isLoadingRecent: isLoadingRecent, - usePendingItems, - skipLoading: !isLoadingMore, - }; -} - -export function expandNotificationsFail(error, isLoadingMore) { - return { - type: NOTIFICATIONS_EXPAND_FAIL, - error, - skipLoading: !isLoadingMore, - skipAlert: !isLoadingMore || error.name === 'AbortError', - }; -} - -export function scrollTopNotifications(top) { - return { - type: NOTIFICATIONS_SCROLL_TOP, - top, - }; -} - -export function setFilter (filterType) { - return dispatch => { - dispatch({ - type: NOTIFICATIONS_FILTER_SET, - path: ['notifications', 'quickFilter', 'active'], - value: filterType, - }); - dispatch(expandNotifications({ forceLoad: true })); - dispatch(saveSettings()); - }; -} - -export const mountNotifications = () => ({ - type: NOTIFICATIONS_MOUNT, -}); - -export const unmountNotifications = () => ({ - type: NOTIFICATIONS_UNMOUNT, -}); - - -export const markNotificationsAsRead = () => ({ - type: NOTIFICATIONS_MARK_AS_READ, -}); - // Browser support export function setupBrowserNotifications() { return dispatch => { diff --git a/app/javascript/mastodon/actions/notifications_migration.tsx b/app/javascript/mastodon/actions/notifications_migration.tsx deleted file mode 100644 index cd9f5ca3d6..0000000000 --- a/app/javascript/mastodon/actions/notifications_migration.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createAppAsyncThunk } from 'mastodon/store'; - -import { fetchNotifications } from './notification_groups'; - -export const initializeNotifications = createAppAsyncThunk( - 'notifications/initialize', - (_, { dispatch }) => { - void dispatch(fetchNotifications()); - }, -); diff --git a/app/javascript/mastodon/actions/notifications_typed.ts b/app/javascript/mastodon/actions/notifications_typed.ts index 88d942d45e..3eb1230666 100644 --- a/app/javascript/mastodon/actions/notifications_typed.ts +++ b/app/javascript/mastodon/actions/notifications_typed.ts @@ -9,7 +9,6 @@ export const notificationsUpdate = createAction( ...args }: { notification: ApiNotificationJSON; - usePendingItems: boolean; playSound: boolean; }) => ({ payload: args, diff --git a/app/javascript/mastodon/actions/polls.js b/app/javascript/mastodon/actions/polls.js deleted file mode 100644 index aa49341444..0000000000 --- a/app/javascript/mastodon/actions/polls.js +++ /dev/null @@ -1,61 +0,0 @@ -import api from '../api'; - -import { importFetchedPoll } from './importer'; - -export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; -export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; -export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; - -export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; -export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; -export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; - -export const vote = (pollId, choices) => (dispatch) => { - dispatch(voteRequest()); - - api().post(`/api/v1/polls/${pollId}/votes`, { choices }) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(voteSuccess(data)); - }) - .catch(err => dispatch(voteFail(err))); -}; - -export const fetchPoll = pollId => (dispatch) => { - dispatch(fetchPollRequest()); - - api().get(`/api/v1/polls/${pollId}`) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(fetchPollSuccess(data)); - }) - .catch(err => dispatch(fetchPollFail(err))); -}; - -export const voteRequest = () => ({ - type: POLL_VOTE_REQUEST, -}); - -export const voteSuccess = poll => ({ - type: POLL_VOTE_SUCCESS, - poll, -}); - -export const voteFail = error => ({ - type: POLL_VOTE_FAIL, - error, -}); - -export const fetchPollRequest = () => ({ - type: POLL_FETCH_REQUEST, -}); - -export const fetchPollSuccess = poll => ({ - type: POLL_FETCH_SUCCESS, - poll, -}); - -export const fetchPollFail = error => ({ - type: POLL_FETCH_FAIL, - error, -}); diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts new file mode 100644 index 0000000000..28f729394b --- /dev/null +++ b/app/javascript/mastodon/actions/polls.ts @@ -0,0 +1,40 @@ +import { apiGetPoll, apiPollVote } from 'mastodon/api/polls'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; +import { createPollFromServerJSON } from 'mastodon/models/poll'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'mastodon/store/typed_functions'; + +import { importPolls } from './importer/polls'; + +export const importFetchedPoll = createAppAsyncThunk( + 'poll/importFetched', + (args: { poll: ApiPollJSON }, { dispatch, getState }) => { + const { poll } = args; + + dispatch( + importPolls({ + polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))], + }), + ); + }, +); + +export const vote = createDataLoadingThunk( + 'poll/vote', + ({ pollId, choices }: { pollId: string; choices: string[] }) => + apiPollVote(pollId, choices), + async (poll, { dispatch, discardLoadData }) => { + await dispatch(importFetchedPoll({ poll })); + return discardLoadData; + }, +); + +export const fetchPoll = createDataLoadingThunk( + 'poll/fetch', + ({ pollId }: { pollId: string }) => apiGetPoll(pollId), + async (poll, { dispatch }) => { + await dispatch(importFetchedPoll({ poll })); + }, +); diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js index b3d3850e31..647a6bd9fb 100644 --- a/app/javascript/mastodon/actions/push_notifications/registerer.js +++ b/app/javascript/mastodon/actions/push_notifications/registerer.js @@ -33,7 +33,7 @@ const unsubscribe = ({ registration, subscription }) => subscription ? subscription.unsubscribe().then(() => registration) : registration; const sendSubscriptionToBackend = (subscription) => { - const params = { subscription }; + const params = { subscription: { ...subscription.toJSON(), standard: true } }; if (me) { const data = pushNotificationsSetting.get(me); diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js deleted file mode 100644 index bde17ae0db..0000000000 --- a/app/javascript/mastodon/actions/search.js +++ /dev/null @@ -1,215 +0,0 @@ -import { fromJS } from 'immutable'; - -import { searchHistory } from 'mastodon/settings'; - -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; - -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_CLEAR = 'SEARCH_CLEAR'; -export const SEARCH_SHOW = 'SEARCH_SHOW'; - -export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; -export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; -export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; - -export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; -export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; -export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; - -export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; - -export function changeSearch(value) { - return { - type: SEARCH_CHANGE, - value, - }; -} - -export function clearSearch() { - return { - type: SEARCH_CLEAR, - }; -} - -export function submitSearch(type) { - return (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const signedIn = !!getState().getIn(['meta', 'me']); - - if (value.length === 0) { - dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); - return; - } - - dispatch(fetchSearchRequest(type)); - - api().get('/api/v2/search', { - params: { - q: value, - resolve: signedIn, - limit: 11, - type, - }, - }).then(response => { - if (response.data.accounts) { - dispatch(importFetchedAccounts(response.data.accounts)); - } - - if (response.data.statuses) { - dispatch(importFetchedStatuses(response.data.statuses)); - } - - dispatch(fetchSearchSuccess(response.data, value, type)); - dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(fetchSearchFail(error)); - }); - }; -} - -export function fetchSearchRequest(searchType) { - return { - type: SEARCH_FETCH_REQUEST, - searchType, - }; -} - -export function fetchSearchSuccess(results, searchTerm, searchType) { - return { - type: SEARCH_FETCH_SUCCESS, - results, - searchType, - searchTerm, - }; -} - -export function fetchSearchFail(error) { - return { - type: SEARCH_FETCH_FAIL, - error, - }; -} - -export const expandSearch = type => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size - 1; - - dispatch(expandSearchRequest(type)); - - api().get('/api/v2/search', { - params: { - q: value, - type, - offset, - limit: 11, - }, - }).then(({ data }) => { - if (data.accounts) { - dispatch(importFetchedAccounts(data.accounts)); - } - - if (data.statuses) { - dispatch(importFetchedStatuses(data.statuses)); - } - - dispatch(expandSearchSuccess(data, value, type)); - dispatch(fetchRelationships(data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(expandSearchFail(error)); - }); -}; - -export const expandSearchRequest = (searchType) => ({ - type: SEARCH_EXPAND_REQUEST, - searchType, -}); - -export const expandSearchSuccess = (results, searchTerm, searchType) => ({ - type: SEARCH_EXPAND_SUCCESS, - results, - searchTerm, - searchType, -}); - -export const expandSearchFail = error => ({ - type: SEARCH_EXPAND_FAIL, - error, -}); - -export const showSearch = () => ({ - type: SEARCH_SHOW, -}); - -export const openURL = (value, history, onFailure) => (dispatch, getState) => { - const signedIn = !!getState().getIn(['meta', 'me']); - - if (!signedIn) { - if (onFailure) { - onFailure(); - } - - return; - } - - dispatch(fetchSearchRequest()); - - api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { - if (response.data.accounts?.length > 0) { - dispatch(importFetchedAccounts(response.data.accounts)); - history.push(`/@${response.data.accounts[0].acct}`); - } else if (response.data.statuses?.length > 0) { - dispatch(importFetchedStatuses(response.data.statuses)); - history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); - } else if (onFailure) { - onFailure(); - } - - dispatch(fetchSearchSuccess(response.data, value)); - }).catch(err => { - dispatch(fetchSearchFail(err)); - - if (onFailure) { - onFailure(); - } - }); -}; - -export const clickSearchResult = (q, type) => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - - if (previous.some(x => x.get('q') === q && x.get('type') === type)) { - return; - } - - const me = getState().getIn(['meta', 'me']); - const current = previous.add(fromJS({ type, q })).takeLast(4); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const forgetSearchResult = q => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - const me = getState().getIn(['meta', 'me']); - const current = previous.filterNot(result => result.get('q') === q); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const updateSearchHistory = recent => ({ - type: SEARCH_HISTORY_UPDATE, - recent, -}); - -export const hydrateSearch = () => (dispatch, getState) => { - const me = getState().getIn(['meta', 'me']); - const history = searchHistory.get(me); - - if (history !== null) { - dispatch(updateSearchHistory(history)); - } -}; diff --git a/app/javascript/mastodon/actions/search.ts b/app/javascript/mastodon/actions/search.ts new file mode 100644 index 0000000000..13a4ee4432 --- /dev/null +++ b/app/javascript/mastodon/actions/search.ts @@ -0,0 +1,148 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { apiGetSearch } from 'mastodon/api/search'; +import type { ApiSearchType } from 'mastodon/api_types/search'; +import type { + RecentSearch, + SearchType as RecentSearchType, +} from 'mastodon/models/search'; +import { searchHistory } from 'mastodon/settings'; +import { + createDataLoadingThunk, + createAppAsyncThunk, +} from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; + +export const submitSearch = createDataLoadingThunk( + 'search/submit', + async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => { + const signedIn = !!getState().meta.get('me'); + + return apiGetSearch({ + q, + type, + resolve: signedIn, + limit: 11, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: false, + }, +); + +export const expandSearch = createDataLoadingThunk( + 'search/expand', + async ({ type }: { type: ApiSearchType }, { getState }) => { + const q = getState().search.q; + const results = getState().search.results; + const offset = results?.[type].length; + + return apiGetSearch({ + q, + type, + limit: 10, + offset, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const openURL = createDataLoadingThunk( + 'search/openURL', + ({ url }: { url: string }) => + apiGetSearch({ + q: url, + resolve: true, + limit: 1, + }), + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + } else if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const clickSearchResult = createAppAsyncThunk( + 'search/clickResult', + ( + { q, type }: { q: string; type?: RecentSearchType }, + { dispatch, getState }, + ) => { + const previous = getState().search.recent; + + if (previous.some((x) => x.q === q && x.type === type)) { + return; + } + + const me = getState().meta.get('me') as string; + const current = [{ type, q }, ...previous].slice(0, 4); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const forgetSearchResult = createAppAsyncThunk( + 'search/forgetResult', + (q: string, { dispatch, getState }) => { + const previous = getState().search.recent; + const me = getState().meta.get('me') as string; + const current = previous.filter((result) => result.q !== q); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const updateSearchHistory = createAction( + 'search/updateHistory', +); + +export const hydrateSearch = createAppAsyncThunk( + 'search/hydrate', + (_args, { dispatch, getState }) => { + const me = getState().meta.get('me') as string; + const history = searchHistory.get(me) as RecentSearch[] | null; + + if (history !== null) { + dispatch(updateSearchHistory(history)); + } + }, +); diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index fbd89f9d4b..7659fb5f98 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -29,7 +29,7 @@ const debouncedSave = debounce((dispatch, getState) => { api().put('/api/web/settings', { data }) .then(() => dispatch({ type: SETTING_SAVE })) .catch(error => dispatch(showAlertForError(error))); -}, 5000, { trailing: true }); +}, 2000, { leading: true, trailing: true }); export function saveSettings() { return (dispatch, getState) => debouncedSave(dispatch, getState); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 1e4e545d8c..1e5b53c687 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -138,7 +138,7 @@ export function deleteStatus(id, withRedraft = false) { dispatch(deleteStatusRequest(id)); - api().delete(`/api/v1/statuses/${id}`).then(response => { + api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); dispatch(importFetchedAccount(response.data.account)); diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index 8ab75cdc44..e8fec13453 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -1,4 +1,4 @@ -import { Iterable, fromJS } from 'immutable'; +import { fromJS, isIndexed } from 'immutable'; import { hydrateCompose } from './compose'; import { importFetchedAccounts } from './importer'; @@ -9,8 +9,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; const convertState = rawState => fromJS(rawState, (k, v) => - Iterable.isIndexed(v) ? v.toList() : v.toMap()); - + isIndexed(v) ? v.toList() : v.toMap()); export function hydrateStore(rawState) { return dispatch => { diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 30e643363a..478e0cae45 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -11,7 +11,7 @@ import { } from './announcements'; import { updateConversations } from './conversations'; import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; -import { updateNotifications, expandNotifications } from './notifications'; +import { updateNotifications } from './notifications'; import { updateStatus } from './statuses'; import { updateTimeline, @@ -107,9 +107,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti break; } case 'notifications_merged': { - const state = getState(); - if (state.notifications.top || !state.notifications.mounted) - dispatch(expandNotifications({ forceLoad: true, maxId: undefined })); dispatch(refreshStaleNotificationGroups()); break; } 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/compose.ts b/app/javascript/mastodon/api/compose.ts new file mode 100644 index 0000000000..757e9961c9 --- /dev/null +++ b/app/javascript/mastodon/api/compose.ts @@ -0,0 +1,7 @@ +import { apiRequestPut } from 'mastodon/api'; +import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; + +export const apiUpdateMedia = ( + id: string, + params?: { description?: string; focus?: string }, +) => apiRequestPut(`v1/media/${id}`, params); diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts new file mode 100644 index 0000000000..764e8daab2 --- /dev/null +++ b/app/javascript/mastodon/api/instance.ts @@ -0,0 +1,15 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiTermsOfServiceJSON, + ApiPrivacyPolicyJSON, +} from 'mastodon/api_types/instance'; + +export const apiGetTermsOfService = (version?: string) => + apiRequestGet( + version + ? `v1/instance/terms_of_service/${version}` + : 'v1/instance/terms_of_service', + ); + +export const apiGetPrivacyPolicy = () => + apiRequestGet('v1/instance/privacy_policy'); 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/polls.ts b/app/javascript/mastodon/api/polls.ts new file mode 100644 index 0000000000..cb659986f5 --- /dev/null +++ b/app/javascript/mastodon/api/polls.ts @@ -0,0 +1,10 @@ +import { apiRequestGet, apiRequestPost } from 'mastodon/api'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; + +export const apiGetPoll = (pollId: string) => + apiRequestGet(`/v1/polls/${pollId}`); + +export const apiPollVote = (pollId: string, choices: string[]) => + apiRequestPost(`/v1/polls/${pollId}/votes`, { + choices, + }); diff --git a/app/javascript/mastodon/api/search.ts b/app/javascript/mastodon/api/search.ts new file mode 100644 index 0000000000..79b0385fe8 --- /dev/null +++ b/app/javascript/mastodon/api/search.ts @@ -0,0 +1,16 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiSearchType, + ApiSearchResultsJSON, +} from 'mastodon/api_types/search'; + +export const apiGetSearch = (params: { + q: string; + resolve?: boolean; + type?: ApiSearchType; + limit?: number; + offset?: number; +}) => + apiRequestGet('v2/search', { + ...params, + }); 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 fdbd7523fc..3f8b27497f 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -19,7 +19,7 @@ export interface BaseApiAccountJSON { avatar_static: string; bot: boolean; created_at: string; - discoverable: boolean; + discoverable?: boolean; indexable: boolean; display_name: string; emojis: ApiCustomEmojiJSON[]; diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts new file mode 100644 index 0000000000..3a29684b70 --- /dev/null +++ b/app/javascript/mastodon/api_types/instance.ts @@ -0,0 +1,11 @@ +export interface ApiTermsOfServiceJSON { + effective_date: string; + effective: boolean; + succeeded_by: string | null; + content: string; +} + +export interface ApiPrivacyPolicyJSON { + updated_at: string; + content: string; +} 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/polls.ts b/app/javascript/mastodon/api_types/polls.ts index 8181f7b813..275ca29fd7 100644 --- a/app/javascript/mastodon/api_types/polls.ts +++ b/app/javascript/mastodon/api_types/polls.ts @@ -18,6 +18,6 @@ export interface ApiPollJSON { options: ApiPollOptionJSON[]; emojis: ApiCustomEmojiJSON[]; - voted: boolean; - own_votes: number[]; + voted?: boolean; + own_votes?: number[]; } diff --git a/app/javascript/mastodon/api_types/search.ts b/app/javascript/mastodon/api_types/search.ts new file mode 100644 index 0000000000..795cbb2b41 --- /dev/null +++ b/app/javascript/mastodon/api_types/search.ts @@ -0,0 +1,11 @@ +import type { ApiAccountJSON } from './accounts'; +import type { ApiStatusJSON } from './statuses'; +import type { ApiHashtagJSON } from './tags'; + +export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags'; + +export interface ApiSearchResultsJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + hashtags: ApiHashtagJSON[]; +} 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/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 = @@ -47,9 +54,12 @@ export const AltTextBadge: React.FC<{ > {({ props }) => (

- diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 46314af309..f49abfd2b3 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect } from 'react'; import { useIntl, defineMessages } from 'react-intl'; +import classNames from 'classnames'; + import { useIdentity } from '@/mastodon/identity_context'; import { fetchRelationships, followAccount } from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; @@ -20,7 +22,8 @@ const messages = defineMessages({ export const FollowButton: React.FC<{ accountId?: string; -}> = ({ accountId }) => { + compact?: boolean; +}> = ({ accountId, compact }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { signedIn } = useIdentity(); @@ -52,7 +55,7 @@ export const FollowButton: React.FC<{ ); } - if (!relationship) return; + if (!relationship || !accountId) return; if (accountId === me) { return; @@ -88,8 +91,10 @@ export const FollowButton: React.FC<{ {label} @@ -99,8 +104,14 @@ export const FollowButton: React.FC<{ return (
- ); -}; +GIFV.displayName = 'GIFV'; diff --git a/app/javascript/mastodon/components/hashtag.tsx b/app/javascript/mastodon/components/hashtag.tsx index 8963e4a40d..30c20e0abd 100644 --- a/app/javascript/mastodon/components/hashtag.tsx +++ b/app/javascript/mastodon/components/hashtag.tsx @@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; +import type { Hashtag as HashtagType } from 'mastodon/models/tags'; interface SilentErrorBoundaryProps { children: React.ReactNode; @@ -80,6 +81,22 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => ( /> ); +export const CompatibilityHashtag: React.FC<{ + hashtag: HashtagType; +}> = ({ hashtag }) => ( + (day.uses as unknown as number) * 1) + .reverse()} + /> +); + export interface HashtagProps { className?: string; description?: React.ReactNode; @@ -134,7 +151,7 @@ export const Hashtag: React.FC = ({ 0)} + data={history ?? Array.from(Array(7)).map(() => 0)} > diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index 057ef1aaed..38c3306f30 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -8,8 +8,8 @@ import type { UsePopperOptions, } from 'react-overlays/esm/usePopper'; -import { useTimeout } from 'mastodon/../hooks/useTimeout'; import { HoverCardAccount } from 'mastodon/components/hover_card_account'; +import { useTimeout } from 'mastodon/hooks/useTimeout'; const offset = [-12, 4] as OffsetValue; const enterDelay = 750; diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index e38e387712..179df83627 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -149,6 +149,7 @@ export class IconButton extends PureComponent { onClick={this.handleClick} onMouseDown={this.handleMouseDown} onKeyDown={this.handleKeyDown} + // eslint-disable-next-line @typescript-eslint/no-deprecated onKeyPress={this.handleKeyPress} style={style} tabIndex={tabIndex} diff --git a/app/javascript/mastodon/components/load_gap.tsx b/app/javascript/mastodon/components/load_gap.tsx index 544b5e1461..6cbdee6ce5 100644 --- a/app/javascript/mastodon/components/load_gap.tsx +++ b/app/javascript/mastodon/components/load_gap.tsx @@ -1,9 +1,10 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; const messages = defineMessages({ load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, @@ -17,10 +18,12 @@ interface Props { export const LoadGap = ({ disabled, param, onClick }: Props) => { const intl = useIntl(); + const [loading, setLoading] = useState(false); const handleClick = useCallback(() => { + setLoading(true); onClick(param); - }, [param, onClick]); + }, [setLoading, param, onClick]); return ( ); }; diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index e059978442..5132316600 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -97,16 +97,16 @@ class Item extends PureComponent { height = 50; } - if (attachment.get('description')?.length > 0) { - badges.push(); - } - const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + if (description?.length > 0) { + badges.push(); + } + if (attachment.get('type') === 'unknown') { return (
- + {description}
-
+
{children}
diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx index 7b836f00b1..1326131009 100644 --- a/app/javascript/mastodon/components/poll.jsx +++ b/app/javascript/mastodon/components/poll.jsx @@ -33,20 +33,17 @@ const messages = defineMessages({ }, }); -const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { - obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); - return obj; -}, {}); - class Poll extends ImmutablePureComponent { static propTypes = { identity: identityContextPropShape, - poll: ImmutablePropTypes.map, + poll: ImmutablePropTypes.record.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 +114,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 = () => { @@ -144,7 +145,7 @@ class Poll extends ImmutablePureComponent { let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); if (!titleHtml) { - const emojiMap = makeEmojiMap(poll); + const emojiMap = emojiMap(poll); titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); } @@ -232,7 +233,7 @@ class Poll extends ImmutablePureComponent {
- {!showResults && } + {!showResults && } {!showResults && <> · } {showResults && !this.props.disabled && <> · } {votesCount} diff --git a/app/javascript/mastodon/components/regeneration_indicator.jsx b/app/javascript/mastodon/components/regeneration_indicator.jsx deleted file mode 100644 index d42a7d7c72..0000000000 --- a/app/javascript/mastodon/components/regeneration_indicator.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -import illustration from '@/images/elephant_ui_working.svg'; - -const RegenerationIndicator = () => ( -
-
- -
- -
- - -
-
-); - -export default RegenerationIndicator; diff --git a/app/javascript/mastodon/components/regeneration_indicator.tsx b/app/javascript/mastodon/components/regeneration_indicator.tsx new file mode 100644 index 0000000000..e26b93eb4f --- /dev/null +++ b/app/javascript/mastodon/components/regeneration_indicator.tsx @@ -0,0 +1,26 @@ +import { FormattedMessage } from 'react-intl'; + +import { GIF } from './gif'; + +export const RegenerationIndicator: React.FC = () => ( +
+ + +
+ + + + +
+
+); diff --git a/app/javascript/mastodon/components/relative_timestamp.tsx b/app/javascript/mastodon/components/relative_timestamp.tsx index ac385e88c6..6253525091 100644 --- a/app/javascript/mastodon/components/relative_timestamp.tsx +++ b/app/javascript/mastodon/components/relative_timestamp.tsx @@ -1,6 +1,6 @@ import { Component } from 'react'; -import type { IntlShape } from 'react-intl'; +import type { MessageDescriptor, PrimitiveType, IntlShape } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl'; const messages = defineMessages({ @@ -102,7 +102,13 @@ const getUnitDelay = (units: string) => { }; export const timeAgoString = ( - intl: Pick, + intl: { + formatDate: IntlShape['formatDate']; + formatMessage: ( + { id, defaultMessage }: MessageDescriptor, + values?: Record, + ) => string; + }, date: Date, now: number, year: number, diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 29439fdf55..22ec18afa9 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -80,6 +80,8 @@ class ScrollableList extends PureComponent { children: PropTypes.node, bindToDocument: PropTypes.bool, preventScroll: PropTypes.bool, + footer: PropTypes.node, + className: PropTypes.string, }; static defaultProps = { @@ -324,7 +326,7 @@ class ScrollableList extends PureComponent { }; render () { - const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, className, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = Children.count(children); @@ -335,21 +337,23 @@ class ScrollableList extends PureComponent { if (showLoading) { scrollableArea = (
-
- {prepend} -
+ {prepend} + +
+ + {footer}
); } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = ( -
-
- {prepend} +
+ {prepend} +
{loadPending} {Children.map(this.props.children, (child, index) => ( @@ -375,6 +379,8 @@ class ScrollableList extends PureComponent { {!hasMore && append}
+ + {footer}
); } else { @@ -385,6 +391,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..989ac7f006 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({ @@ -42,7 +42,7 @@ class ServerBanner extends PureComponent { return (
- {domain}, mastodon: Mastodon }} /> + {domain}, mastodon: Mastodon }} />
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 4792136368..d4eb1463f0 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,23 @@ class Status extends ImmutablePureComponent { }; handleClick = e => { - if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) { + this._openStatus(); + } else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) { + this._openStatus(true); + } + }; + + 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 = () => { @@ -288,6 +281,10 @@ class Status extends ImmutablePureComponent { }; handleHotkeyOpen = () => { + this._openStatus(); + }; + + _openStatus = (newTab = false) => { if (this.props.onClick) { this.props.onClick(); return; @@ -300,7 +297,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 (newTab) { + window.open(path, '_blank', 'noopener'); + } else { + history.push(path); + } }; handleHotkeyOpenProfile = () => { @@ -330,7 +333,7 @@ class Status extends ImmutablePureComponent { const { onToggleHidden } = this.props; const status = this._properStatus(); - if (status.get('matched_filters')) { + if (this.props.status.get('matched_filters')) { const expandedBecauseOfCW = !status.get('hidden') || status.get('spoiler_text').length === 0; const expandedBecauseOfFilter = this.state.showDespiteFilter; @@ -390,21 +393,11 @@ class Status extends ImmutablePureComponent { toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, openMedia: this.handleHotkeyOpenMedia, + onTranslate: this.handleTranslate, }; 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 +416,7 @@ class Status extends ImmutablePureComponent { prepend = (
- }} /> + }} />
); @@ -444,6 +437,20 @@ 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) { @@ -520,7 +527,7 @@ class Status extends ImmutablePureComponent { ); } - } else if (status.get('spoiler_text').length === 0 && status.get('card')) { + } else if (status.get('card')) { media = ( @@ -548,20 +554,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 94cd7e3e07..9cd2d8d20c 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -47,6 +47,7 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, + removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, @@ -271,8 +272,6 @@ class StatusActionBar extends ImmutablePureComponent { if (signedIn) { menu.push(null); - menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); - if (writtenByMe && pinnableStatus) { menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); } @@ -371,6 +370,9 @@ class StatusActionBar extends ImmutablePureComponent { reblogIconComponent = RepeatDisabledIcon; } + + const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark); + const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); return ( @@ -382,10 +384,10 @@ class StatusActionBar extends ImmutablePureComponent {
- +
- +
lang[0] === translation.get('detected_source_language')); - const languageName = language ? language[2] : translation.get('detected_source_language'); + const languageName = language ? language[1] : translation.get('detected_source_language'); const provider = translation.get('provider'); return ( @@ -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/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index c6cacbd2b2..3091e2a2a0 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -6,7 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { debounce } from 'lodash'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; -import RegenerationIndicator from 'mastodon/components/regeneration_indicator'; +import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; import StatusContainer from '../containers/status_container'; 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/compose_container.jsx b/app/javascript/mastodon/containers/compose_container.jsx index 171f14d3b2..a2513cc552 100644 --- a/app/javascript/mastodon/containers/compose_container.jsx +++ b/app/javascript/mastodon/containers/compose_container.jsx @@ -1,6 +1,7 @@ import { Provider } from 'react-redux'; import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis'; +import { fetchServer } from 'mastodon/actions/server'; import { hydrateStore } from 'mastodon/actions/store'; import { Router } from 'mastodon/components/router'; import Compose from 'mastodon/features/standalone/compose'; @@ -13,6 +14,7 @@ if (initialState) { } store.dispatch(fetchCustomEmojis()); +store.dispatch(fetchServer()); const ComposeContainer = () => ( 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..7ca840138d 100644 --- a/app/javascript/mastodon/containers/poll_container.js +++ b/app/javascript/mastodon/containers/poll_container.js @@ -2,25 +2,37 @@ 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'; const mapDispatchToProps = (dispatch, { pollId }) => ({ refresh: debounce( () => { - dispatch(fetchPoll(pollId)); + dispatch(fetchPoll({ pollId })); }, 1000, { leading: true }, ), onVote (choices) { - dispatch(vote(pollId, 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 }) => ({ - poll: state.getIn(['polls', pollId]), + poll: state.polls.get(pollId), }); export default connect(mapStateToProps, mapDispatchToProps)(Poll); diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 4a88e5b5e4..9e3b73e2ff 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -13,12 +13,12 @@ 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'; +import { LinkFooter } from 'mastodon/features/ui/components/link_footer'; const messages = defineMessages({ title: { id: 'column.about', defaultMessage: 'About' }, @@ -140,7 +140,7 @@ class About extends PureComponent { ) : (extendedDescription.get('content')?.length > 0 ? (
) : (

@@ -165,8 +165,7 @@ class About extends PureComponent {
{!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? ( -

+

) : (
    {server.get('rules').map(rule => ( diff --git a/app/javascript/mastodon/features/account/components/domain_pill.jsx b/app/javascript/mastodon/features/account/components/domain_pill.jsx deleted file mode 100644 index 0dadb947f9..0000000000 --- a/app/javascript/mastodon/features/account/components/domain_pill.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import PropTypes from 'prop-types'; -import { useState, useRef, useCallback } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import Overlay from 'react-overlays/Overlay'; - - - -import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; -import BadgeIcon from '@/material-icons/400-24px/badge.svg?react'; -import GlobeIcon from '@/material-icons/400-24px/globe.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -export const DomainPill = ({ domain, username, isSelf }) => { - const [open, setOpen] = useState(false); - const [expanded, setExpanded] = useState(false); - const triggerRef = useRef(null); - - const handleClick = useCallback(() => { - setOpen(!open); - }, [open, setOpen]); - - const handleExpandClick = useCallback(() => { - setExpanded(!expanded); - }, [expanded, setExpanded]); - - return ( - <> - - - - {({ props }) => ( -
    -
    -
    -

    -
    - -
    -
    {isSelf ? : }
    -
    @{username}@{domain}
    -
    - -
    -
    -
    - -
    -
    -

    {isSelf ? : }

    -
    -
    - -
    -
    - -
    -
    -

    {isSelf ? : }

    -
    -
    -
    - -

    {isSelf ? }} /> : }} />}

    - - {expanded && ( - <> -

    -

    - - )} -
    - )} -
    - - ); -}; - -DomainPill.propTypes = { - username: PropTypes.string.isRequired, - domain: PropTypes.string.isRequired, - isSelf: PropTypes.bool, -}; diff --git a/app/javascript/mastodon/features/account/components/domain_pill.tsx b/app/javascript/mastodon/features/account/components/domain_pill.tsx new file mode 100644 index 0000000000..c60397448b --- /dev/null +++ b/app/javascript/mastodon/features/account/components/domain_pill.tsx @@ -0,0 +1,202 @@ +import { useState, useRef, useCallback, useId } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + +import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; +import BadgeIcon from '@/material-icons/400-24px/badge.svg?react'; +import GlobeIcon from '@/material-icons/400-24px/globe.svg?react'; +import { Icon } from 'mastodon/components/icon'; + +export const DomainPill: React.FC<{ + domain: string; + username: string; + isSelf: boolean; +}> = ({ domain, username, isSelf }) => { + const accessibilityId = useId(); + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(false); + const triggerRef = useRef(null); + + const handleClick = useCallback(() => { + setOpen(!open); + }, [open, setOpen]); + + const handleExpandClick = useCallback(() => { + setExpanded(!expanded); + }, [expanded, setExpanded]); + + return ( + <> + + + + {({ props }) => ( +
    +
    +
    + +
    +

    + +

    +
    + +
    +
    + {isSelf ? ( + + ) : ( + + )} +
    +
    + @{username}@{domain} +
    +
    + +
    +
    +
    + +
    + +
    +
    + +
    +

    + {isSelf ? ( + + ) : ( + + )} +

    +
    +
    + +
    +
    + +
    + +
    +
    + +
    +

    + {isSelf ? ( + + ) : ( + + )} +

    +
    +
    +
    + +

    + {isSelf ? ( + ( + + ), + }} + /> + ) : ( + ( + + ), + }} + /> + )} +

    + + {expanded && ( + <> +

    + +

    +

    + +

    + + )} +
    + )} +
    + + ); +}; diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx deleted file mode 100644 index 6583c1f604..0000000000 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ /dev/null @@ -1,515 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; -import { Helmet } from 'react-helmet'; -import { NavLink, withRouter } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import CheckIcon from '@/material-icons/400-24px/check.svg?react'; -import LockIcon from '@/material-icons/400-24px/lock.svg?react'; -import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; -import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react'; -import ShareIcon from '@/material-icons/400-24px/share.svg?react'; -import { Avatar } from 'mastodon/components/avatar'; -import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge'; -import { Button } from 'mastodon/components/button'; -import { CopyIconButton } from 'mastodon/components/copy_icon_button'; -import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters'; -import { Icon } from 'mastodon/components/icon'; -import { IconButton } from 'mastodon/components/icon_button'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { ShortNumber } from 'mastodon/components/short_number'; -import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state'; -import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; -import { WithRouterPropTypes } from 'mastodon/utils/react_router'; - -import AccountNoteContainer from '../containers/account_note_container'; -import FollowRequestNoteContainer from '../containers/follow_request_note_container'; - -import { DomainPill } from './domain_pill'; - -const messages = defineMessages({ - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, - mutual: { id: 'account.mutual', defaultMessage: 'Mutual' }, - cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, - requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, - unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, - edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, - linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, - account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' }, - mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, - direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' }, - unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, - block: { id: 'account.block', defaultMessage: 'Block @{name}' }, - mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, - report: { id: 'account.report', defaultMessage: 'Report @{name}' }, - share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, - copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' }, - media: { id: 'account.media', defaultMessage: 'Media' }, - blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, - unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, - hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, - showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, - enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' }, - disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' }, - pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' }, - preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, - follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, - favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, - lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, - followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, - blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, - domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' }, - mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, - endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, - unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, - add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, - admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, - admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' }, - languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' }, - openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, -}); - -const titleFromAccount = account => { - const displayName = account.get('display_name'); - const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${localDomain}` : account.get('acct'); - const prefix = displayName.trim().length === 0 ? account.get('username') : displayName; - - return `${prefix} (@${acct})`; -}; - -const messageForFollowButton = relationship => { - if(!relationship) return messages.follow; - - if (relationship.get('following') && relationship.get('followed_by')) { - return messages.mutual; - } else if (relationship.get('following') || relationship.get('requested')) { - return messages.unfollow; - } else if (relationship.get('followed_by')) { - return messages.followBack; - } else { - return messages.follow; - } -}; - -const dateFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', -}; - -class Header extends ImmutablePureComponent { - - static propTypes = { - identity: identityContextPropShape, - account: ImmutablePropTypes.record, - identity_props: ImmutablePropTypes.list, - onFollow: PropTypes.func.isRequired, - onBlock: PropTypes.func.isRequired, - onMention: PropTypes.func.isRequired, - onDirect: PropTypes.func.isRequired, - onReblogToggle: PropTypes.func.isRequired, - onNotifyToggle: PropTypes.func.isRequired, - onReport: PropTypes.func.isRequired, - onMute: PropTypes.func.isRequired, - onBlockDomain: PropTypes.func.isRequired, - onUnblockDomain: PropTypes.func.isRequired, - onEndorseToggle: PropTypes.func.isRequired, - onAddToList: PropTypes.func.isRequired, - onEditAccountNote: PropTypes.func.isRequired, - onChangeLanguages: PropTypes.func.isRequired, - onInteractionModal: PropTypes.func.isRequired, - onOpenAvatar: PropTypes.func.isRequired, - onOpenURL: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - domain: PropTypes.string.isRequired, - hidden: PropTypes.bool, - ...WithRouterPropTypes, - }; - - setRef = c => { - this.node = c; - }; - - openEditProfile = () => { - window.open('/settings/profile', '_blank'); - }; - - isStatusesPageActive = (match, location) => { - if (!match) { - return false; - } - - return !location.pathname.match(/\/(followers|following)\/?$/); - }; - - handleMouseEnter = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-original'); - } - }; - - handleMouseLeave = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-static'); - } - }; - - handleAvatarClick = e => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.props.onOpenAvatar(); - } - }; - - handleShare = () => { - const { account } = this.props; - - navigator.share({ - url: account.get('url'), - }).catch((e) => { - if (e.name !== 'AbortError') console.error(e); - }); - }; - - handleHashtagClick = e => { - const { history } = this.props; - const value = e.currentTarget.textContent.replace(/^#/, ''); - - if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - history.push(`/tags/${value}`); - } - }; - - handleMentionClick = e => { - const { history, onOpenURL } = this.props; - - if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - - const link = e.currentTarget; - - onOpenURL(link.href, history, () => { - window.location = link.href; - }); - } - }; - - _attachLinkEvents () { - const node = this.node; - - if (!node) { - return; - } - - const links = node.querySelectorAll('a'); - - let link; - - for (var i = 0; i < links.length; ++i) { - link = links[i]; - - if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { - link.addEventListener('click', this.handleHashtagClick, false); - } else if (link.classList.contains('mention')) { - link.addEventListener('click', this.handleMentionClick, false); - } - } - } - - componentDidMount () { - this._attachLinkEvents(); - } - - componentDidUpdate () { - this._attachLinkEvents(); - } - - render () { - const { account, hidden, intl } = this.props; - const { signedIn, permissions } = this.props.identity; - - if (!account) { - return null; - } - - const suspended = account.get('suspended'); - const isRemote = account.get('acct') !== account.get('username'); - const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null; - - let actionBtn, bellBtn, lockedIcon, shareBtn; - - let info = []; - let menu = []; - - if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) { - info.push(); - } - - if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) { - info.push(); - } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) { - info.push(); - } - - if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) { - bellBtn = ; - } - - if ('share' in navigator) { - shareBtn = ; - } else { - shareBtn = ; - } - - if (me !== account.get('id')) { - if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded - actionBtn = ; - } else if (!account.getIn(['relationship', 'blocking'])) { - actionBtn =
    + ); + } else if (!relationship?.blocking) { + actionBtn = ( + + + + {({ props }) => ( +
    +

    {node}

    , + p: (node) =>

    {node}

    , + ul: (node) =>
      {node}
    , + li: (node) =>
  1. {node}
  2. , + }} + /> +
    + )} +
    + + ); +}; diff --git a/app/javascript/mastodon/features/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx new file mode 100644 index 0000000000..80c4f36105 --- /dev/null +++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx @@ -0,0 +1,537 @@ +import { + useState, + useCallback, + useRef, + useEffect, + useImperativeHandle, + forwardRef, +} from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; + +import Textarea from 'react-textarea-autosize'; +import { length } from 'stringz'; +// eslint-disable-next-line import/extensions +import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +// eslint-disable-next-line import/no-extraneous-dependencies +import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; + +import { showAlertForError } from 'mastodon/actions/alerts'; +import { uploadThumbnail } from 'mastodon/actions/compose'; +import { changeUploadCompose } from 'mastodon/actions/compose_typed'; +import { Button } from 'mastodon/components/button'; +import { GIFV } from 'mastodon/components/gifv'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { Skeleton } from 'mastodon/components/skeleton'; +import Audio from 'mastodon/features/audio'; +import { CharacterCounter } from 'mastodon/features/compose/components/character_counter'; +import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; +import Video, { getPointerPosition } from 'mastodon/features/video'; +import { me } from 'mastodon/initial_state'; +import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { assetHost } from 'mastodon/utils/config'; + +import { InfoButton } from './components/info_button'; + +const messages = defineMessages({ + placeholderVisual: { + id: 'alt_text_modal.describe_for_people_with_visual_impairments', + defaultMessage: 'Describe this for people with visual impairments…', + }, + placeholderHearing: { + id: 'alt_text_modal.describe_for_people_with_hearing_impairments', + defaultMessage: 'Describe this for people with hearing impairments…', + }, + discardMessage: { + id: 'confirmations.discard_edit_media.message', + defaultMessage: + 'You have unsaved changes to the media description or preview, discard them anyway?', + }, + discardConfirm: { + id: 'confirmations.discard_edit_media.confirm', + defaultMessage: 'Discard', + }, +}); + +const MAX_LENGTH = 1500; + +type FocalPoint = [number, number]; + +const UploadButton: React.FC<{ + children: React.ReactNode; + onSelectFile: (arg0: File) => void; + mimeTypes: string; +}> = ({ children, onSelectFile, mimeTypes }) => { + const fileRef = useRef(null); + + const handleClick = useCallback(() => { + fileRef.current?.click(); + }, []); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (file) { + onSelectFile(file); + } + }, + [onSelectFile], + ); + + return ( + + ); +}; + +const Preview: React.FC<{ + mediaId: string; + position: FocalPoint; + onPositionChange: (arg0: FocalPoint) => void; +}> = ({ mediaId, position, onPositionChange }) => { + const media = useAppSelector((state) => + ( + (state.compose as ImmutableMap).get( + 'media_attachments', + ) as ImmutableList + ).find((x) => x.get('id') === mediaId), + ); + const account = useAppSelector((state) => + me ? state.accounts.get(me) : undefined, + ); + + const [dragging, setDragging] = useState(false); + const [x, y] = position; + const nodeRef = useRef(null); + const draggingRef = useRef(false); + + const setRef = useCallback( + (e: HTMLImageElement | HTMLVideoElement | null) => { + nodeRef.current = e; + }, + [], + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) { + return; + } + + const { x, y } = getPointerPosition(nodeRef.current, e); + setDragging(true); + draggingRef.current = true; + onPositionChange([x, y]); + }, + [setDragging, onPositionChange], + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const { x, y } = getPointerPosition(nodeRef.current, e); + setDragging(true); + draggingRef.current = true; + onPositionChange([x, y]); + }, + [setDragging, onPositionChange], + ); + + useEffect(() => { + const handleMouseUp = () => { + setDragging(false); + draggingRef.current = false; + }; + + const handleMouseMove = (e: MouseEvent) => { + if (draggingRef.current) { + const { x, y } = getPointerPosition(nodeRef.current, e); + onPositionChange([x, y]); + } + }; + + const handleTouchEnd = () => { + setDragging(false); + draggingRef.current = false; + }; + + const handleTouchMove = (e: TouchEvent) => { + if (draggingRef.current) { + const { x, y } = getPointerPosition(nodeRef.current, e); + onPositionChange([x, y]); + } + }; + + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchmove', handleTouchMove); + + return () => { + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchmove', handleTouchMove); + }; + }, [setDragging, onPositionChange]); + + if (!media) { + return null; + } + + if (media.get('type') === 'image') { + return ( +
    + +
    +
    + ); + } else if (media.get('type') === 'gifv') { + return ( +
    + +
    +
    + ); + } else if (media.get('type') === 'video') { + return ( +