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 0376af4bcc..0135379d6e 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,9 +1,6 @@ -[production] defaults > 0.2% +firefox >= 78 ios >= 15.6 not dead not OperaMini all - -[development] -supports es6-module diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c6dcc4d46a..3aa0bbf7da 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -11,5 +11,8 @@ RUN apt-get update && \ export DEBIAN_FRONTEND=noninteractive && \ apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev +# Disable download prompt for Corepack +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + # Move welcome message to where VS Code expects it COPY .devcontainer/welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 1e2e1ba7de..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.5.7 + image: libretranslate/libretranslate:v1.6.2 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.env.production.sample b/.env.production.sample index 5939c12146..f7df3b2619 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -1,5 +1,5 @@ # This is a sample configuration file. You can generate your configuration -# with the `rake mastodon:setup` interactive setup wizard, but to customize +# with the `bundle exec rails mastodon:setup` interactive setup wizard, but to customize # your setup even further, you'll need to edit it manually. This sample does # not demonstrate all available configuration options. Please look at # https://docs.joinmastodon.org/admin/config/ for the full documentation. @@ -68,15 +68,25 @@ DB_PORT=5432 # Secrets # ------- -# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose) +# Generate each with the `RAILS_ENV=production bundle exec rails secret` task (`docker-compose run --rm web bundle exec rails secret` if you use docker compose) # ------- SECRET_KEY_BASE= OTP_SECRET= +# Encryption secrets +# ------------------ +# Must be available (and set to same values) for all server processes +# These are private/secret values, do not share outside hosting environment +# Use `bin/rails db:encryption:init` to generate fresh secrets +# Do NOT change these secrets once in use, as this would cause data loss and other issues +# ------------------ +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= # Web Push # -------- -# Generate with `rake mastodon:webpush:generate_vapid_key` (first is the private key, second is the public one) +# Generate with `bundle exec rails mastodon:webpush:generate_vapid_key` (first is the private key, second is the public one) # You should only generate this once per instance. If you later decide to change it, all push subscription will # be invalidated, requiring the users to access the website again to resubscribe. # -------- diff --git a/.eslintrc.js b/.eslintrc.js index 66a0f12ba1..1a7d0e218a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,7 +64,6 @@ module.exports = defineConfig({ 'indent': ['error', 2], 'jsx-quotes': ['error', 'prefer-single'], 'semi': ['error', 'always'], - 'no-case-declarations': 'off', 'no-catch-shadow': 'error', 'no-console': [ 'warn', @@ -110,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', @@ -333,7 +332,7 @@ module.exports = defineConfig({ ], parserOptions: { - project: true, + projectService: true, tsconfigRootDir: __dirname, }, diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml index 20e27d103c..36b18a1287 100644 --- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml @@ -1,5 +1,5 @@ name: Bug Report (Web Interface) -description: If you are using Mastodon's web interface and something is not working as expected +description: There is a problem using Mastodon's web interface. labels: [bug, 'status/to triage', 'area/web interface'] body: - type: markdown @@ -47,8 +47,8 @@ body: attributes: label: Mastodon version description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 validations: required: true - type: input @@ -56,7 +56,7 @@ body: label: Browser name and version description: | What browser are you using when getting this bug? Please specify the version as well. - placeholder: Firefox 105.0.3 + placeholder: Firefox 131.0.0 validations: required: true - type: input @@ -64,7 +64,7 @@ body: label: Operating system description: | What OS are you running? Please specify the version as well. - placeholder: macOS 13.4.1 + placeholder: macOS 15.0.1 validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml index 49d5f57209..45bd689f1e 100644 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report (server / API) description: | - If something is not working as expected, but is not from using the web interface. + There is a problem with the HTTP server, REST API, ActivityPub interaction, etc. labels: [bug, 'status/to triage'] body: - type: markdown @@ -48,8 +48,8 @@ body: attributes: label: Mastodon version description: | - This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` - placeholder: v4.1.2 + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 validations: required: false - type: textarea @@ -59,7 +59,7 @@ body: Any additional technical details you may have, like logs or error traces value: | If this is happening on your own Mastodon server, please fill out those: - - Ruby version: (from `ruby --version`, eg. v3.1.2) - - Node.js version: (from `node --version`, eg. v18.16.0) + - Ruby version: (from `ruby --version`, eg. v3.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 new file mode 100644 index 0000000000..1f70046ed4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -0,0 +1,73 @@ +name: Deployment troubleshooting +description: | + You are a server administrator and you are encountering a technical issue during installation, upgrade or operations of Mastodon. +labels: [bug, 'status/to triage'] +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: true + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1` + placeholder: v4.3.0 + validations: + required: false + - type: textarea + attributes: + label: Environment + description: | + Details about your environment, like how Mastodon is deployed, if containers are used, version numbers, etc. + value: | + Please at least include those informations: + - Operating system: (eg. Ubuntu 22.04) + - Ruby version: (from `ruby --version`, eg. v3.4.1) + - Node.js version: (from `node --version`, eg. v20.18.0) + validations: + required: false + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have, like logs or error traces + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3.feature_request.yml b/.github/ISSUE_TEMPLATE/4.feature_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/3.feature_request.yml rename to .github/ISSUE_TEMPLATE/4.feature_request.yml diff --git a/.github/codecov.yml b/.github/codecov.yml index 701ba3af8f..21af6d0d45 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -9,3 +9,5 @@ coverage: default: # GitHub status check is not blocking informational: true +github_checks: + annotations: false diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 2cf7bec8ee..8a10676283 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -7,6 +7,7 @@ ':prConcurrentLimitNone', // Remove limit for open PRs at any time. ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. ], + rebaseWhen: 'conflicted', minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it // packageRules order is important, they are applied from top to bottom and are merged, // meaning the most important ones must be at the bottom, for example grouping rules @@ -87,6 +88,7 @@ }, { // Update devDependencies every week, with one grouped PR + matchManagers: ['npm'], matchDepTypes: 'devDependencies', matchUpdateTypes: ['patch', 'minor'], groupName: 'devDependencies (non-major)', @@ -95,8 +97,7 @@ { // Group all eslint-related packages with `eslint` in the same PR matchManagers: ['npm'], - matchPackageNames: ['eslint'], - matchPackagePrefixes: ['eslint-', '@typescript-eslint/'], + matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'eslint (non-major)', }, @@ -112,7 +113,8 @@ }, { // Update @types/* packages every week, with one grouped PR - matchPackagePrefixes: '@types/', + matchManagers: ['npm'], + matchPackageNames: '@types/*', matchUpdateTypes: ['patch', 'minor'], groupName: 'DefinitelyTyped types (non-major)', extends: ['schedule:weekly'], @@ -129,23 +131,21 @@ { // Group all RuboCop packages with `rubocop` in the same PR matchManagers: ['bundler'], - matchPackageNames: ['rubocop'], - matchPackagePrefixes: ['rubocop-'], + matchPackageNames: ['rubocop', 'rubocop-*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'RuboCop (non-major)', }, { // Group all RSpec packages with `rspec` in the same PR matchManagers: ['bundler'], - matchPackageNames: ['rspec'], - matchPackagePrefixes: ['rspec-'], + matchPackageNames: ['rspec', 'rspec-*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'RSpec (non-major)', }, { // Group all opentelemetry-ruby packages in the same PR matchManagers: ['bundler'], - matchPackagePrefixes: ['opentelemetry-'], + matchPackageNames: ['opentelemetry-*'], matchUpdateTypes: ['patch', 'minor'], groupName: 'opentelemetry-ruby (non-major)', }, diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index dbb32af9bf..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@v5 - 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 eddcd80f26..7dda6cb350 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: false cache: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon @@ -35,7 +33,7 @@ jobs: labels: | org.opencontainers.image.description=Nightly build image used for testing purposes flavor: | - latest=true + latest=auto tags: | type=raw,value=edge type=raw,value=nightly @@ -47,8 +45,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: false cache: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon-streaming @@ -56,7 +52,7 @@ jobs: labels: | org.opencontainers.image.description=Nightly build image used for testing purposes flavor: | - latest=true + latest=auto tags: | type=raw,value=edge type=raw,value=nightly diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index 4505151e1a..6ec561d2fb 100644 --- a/.github/workflows/build-push-pr.yml +++ b/.github/workflows/build-push-pr.yml @@ -21,17 +21,17 @@ jobs: uses: actions/checkout@v4 - id: version_vars run: | - echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT + echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT + echo mastodon_short_sha=$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT outputs: metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }} + short_sha: ${{ steps.version_vars.outputs.mastodon_short_sha }} build-image: needs: compute-suffix uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon version_metadata: ${{ needs.compute-suffix.outputs.metadata }} @@ -39,6 +39,7 @@ jobs: latest=auto tags: | type=ref,event=pr + type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }} secrets: inherit build-image-streaming: @@ -46,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: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon-streaming version_metadata: ${{ needs.compute-suffix.outputs.metadata }} @@ -55,4 +54,5 @@ jobs: latest=auto tags: | type=ref,event=pr + type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }} secrets: inherit diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index 8e0fe5dfa8..7508e4ff4e 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: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon # Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages @@ -22,7 +20,7 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.2.') }} + latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} @@ -33,8 +31,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon-streaming # Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml index e9f1862f5d..32a2d534bd 100644 --- a/.github/workflows/build-security.yml +++ b/.github/workflows/build-security.yml @@ -23,8 +23,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: false cache: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon @@ -32,7 +30,7 @@ jobs: labels: | org.opencontainers.image.description=Nightly build image used for testing purposes flavor: | - latest=true + latest=auto tags: | type=raw,value=edge type=raw,value=nightly @@ -44,8 +42,6 @@ jobs: uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile - platforms: linux/amd64,linux/arm64 - use_native_arm64_builder: false cache: false push_to_images: | ghcr.io/${{ github.repository_owner }}/mastodon-streaming @@ -53,7 +49,7 @@ jobs: labels: | org.opencontainers.image.description=Nightly build image used for testing purposes flavor: | - latest=true + latest=auto tags: | type=raw,value=edge type=raw,value=nightly diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index e3e2da0c78..fa28d28f74 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -1,8 +1,10 @@ name: Bundler Audit on: + merge_group: push: - branches-ignore: - - 'dependabot/**' + branches: + - 'main' + - 'stable-*' paths: - 'Gemfile*' - '.ruby-version' @@ -34,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 ceb385933b..c46090c1b5 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -2,9 +2,13 @@ name: Check i18n on: push: - branches: [main] + branches: + - 'main' + - 'stable-*' pull_request: - branches: [main] + branches: + - 'main' + - 'stable-*' env: RAILS_ENV: test @@ -14,7 +18,7 @@ permissions: jobs: check-i18n: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -31,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/codeql.yml b/.github/workflows/codeql.yml index 6fb93b7fef..8690e9ed6d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,15 @@ name: 'CodeQL' on: + merge_group: push: - branches: ['main'] + branches: + - 'main' + - 'stable-*' pull_request: - # The branches below must be a subset of the branches above - branches: ['main'] + branches: + - 'main' + - 'stable-*' schedule: - cron: '22 6 * * 1' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml new file mode 100644 index 0000000000..191eb5bc2c --- /dev/null +++ b/.github/workflows/crowdin-download-stable.yml @@ -0,0 +1,70 @@ +name: Crowdin / Download translations (stable branches) +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + download-translations-stable: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Increase Git http.postBuffer + # This is needed due to a bug in Ubuntu's cURL version? + # See https://github.com/orgs/community/discussions/55820 + run: | + git config --global http.version HTTP/1.1 + git config --global http.postBuffer 157286400 + + # Download the translation files from Crowdin + - name: crowdin action + uses: crowdin/github-action@v2 + with: + config: crowdin-glitch.yml + upload_sources: false + upload_translations: false + download_translations: true + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} + push_translations: false + create_pull_request: false + env: + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # As the files are extracted from a Docker container, they belong to root:root + # We need to fix this before the next steps + - name: Fix file permissions + run: sudo chown -R runner:docker . + + # This is needed to run the normalize step + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Run i18n normalize task + run: bin/i18n-tasks normalize + + # Create or update the pull request + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7.0.6 + with: + commit-message: 'New Crowdin translations' + title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' + author: 'GitHub Actions ' + body: | + New Crowdin translations, automated with GitHub Actions + + See `.github/workflows/crowdin-download.yml` + + This PR will be updated every day with new translations. + + Due to a limitation in GitHub Actions, checks are not running on this PR without manual action. + If you want to run the checks, then close and re-open it. + branch: i18n/crowdin/translations-${{ github.base_ref || github.ref_name }} + base: ${{ github.base_ref || github.ref_name }} + labels: i18n diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 1212e66296..1fdd1e08b4 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -26,7 +26,7 @@ jobs: # Download the translation files from Crowdin - name: crowdin action - uses: crowdin/github-action@v1 + uses: crowdin/github-action@v2 with: config: crowdin-glitch.yml upload_sources: false @@ -49,11 +49,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@v6.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/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index 32c09802b4..c48668d3bc 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -3,7 +3,8 @@ name: Crowdin / Upload translations on: push: branches: - - main + - 'main' + - 'stable-*' paths: - crowdin-glitch.yml - app/javascript/flavours/glitch/locales/en.json @@ -17,19 +18,20 @@ on: jobs: upload-translations: runs-on: ubuntu-latest + if: github.repository == 'glitch-soc/mastodon' steps: - name: Checkout uses: actions/checkout@v4 - name: crowdin action - uses: crowdin/github-action@v1 + uses: crowdin/github-action@v2 with: config: crowdin-glitch.yml upload_sources: true upload_translations: false download_translations: false - crowdin_branch_name: main + crowdin_branch_name: ${{ github.base_ref || github.ref_name }} env: CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 2d483b5022..c10f350a02 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -1,6 +1,10 @@ name: Check formatting on: + merge_group: push: + branches: + - 'main' + - 'stable-*' pull_request: jobs: diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index d3b8035cd8..c1385bf789 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -1,9 +1,10 @@ name: CSS Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' @@ -39,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 ca4b0c80bf..9361358078 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -1,9 +1,10 @@ name: Haml Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - '.github/workflows/haml-lint-problem-matcher.json' - '.github/workflows/lint-haml.yml' @@ -42,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 --parallel --reporter github diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 1c1ecc2b22..7d31a5e20e 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -1,9 +1,10 @@ name: JavaScript Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index b3a89c3caf..87f8aee24e 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -1,13 +1,15 @@ name: Ruby Linting on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' @@ -18,6 +20,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' + - 'bin/rubocop' - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index 8784397a8f..945315d52d 100644 --- a/.github/workflows/rebase-needed.yml +++ b/.github/workflows/rebase-needed.yml @@ -10,6 +10,7 @@ permissions: jobs: label-rebase-needed: runs-on: ubuntu-latest + if: github.repository == 'glitch-soc/mastodon' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml index 980e071897..bde40addd6 100644 --- a/.github/workflows/test-image-build.yml +++ b/.github/workflows/test-image-build.yml @@ -20,7 +20,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 +30,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-js.yml b/.github/workflows/test-js.yml index 481afdba30..e9e43ac9e8 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -1,9 +1,10 @@ name: JavaScript Testing on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' paths: - 'package.json' - 'yarn.lock' diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 3eaf2c2d74..733664b753 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -1,29 +1,30 @@ name: Historical data migration test on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' + - 'lib/tasks/db.rake' + pull_request: + paths: + - 'Gemfile*' + - '.ruby-version' + - '**/*.rb' + - '.github/workflows/test-migrations.yml' + - 'lib/tasks/tests.rake' jobs: - pre_job: - runs-on: ubuntu-latest - - outputs: - should_skip: ${{ steps.skip_check.outputs.should_skip }} - - steps: - - id: skip_check - uses: fkirc/skip-duplicate-actions@v5 - with: - paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations.yml", "lib/tasks/tests.rake"]' - test: runs-on: ubuntu-latest - needs: pre_job - if: needs.pre_job.outputs.should_skip != 'true' strategy: fail-fast: false @@ -32,6 +33,8 @@ jobs: postgres: - 14-alpine - 15-alpine + - 16-alpine + - 17-alpine services: postgres: @@ -61,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 @@ -88,6 +90,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 ef898968d0..1f7f8f93a8 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -1,10 +1,11 @@ name: Ruby Testing on: + merge_group: push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' + branches: + - 'main' + - 'stable-*' pull_request: env: @@ -41,11 +42,24 @@ jobs: with: onlyProduction: 'true' + - name: Cache assets from compilation + uses: actions/cache@v4 + with: + path: | + public/assets + public/packs + public/packs-test + tmp/cache/webpacker + key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }} + ${{ matrix.mode }}-assets-main + ${{ matrix.mode }}-assets + - name: Precompile assets - # Previously had set this, but it's not supported - # export NODE_OPTIONS=--openssl-legacy-provider run: |- - ./bin/rails assets:precompile + bin/rails assets:precompile - name: Archive asset artifacts run: | @@ -93,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 @@ -110,8 +124,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' + - '3.3' - '.ruby-version' steps: - uses: actions/checkout@v4 @@ -129,18 +143,31 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg libpam-dev + additional-system-dependencies: ffmpeg imagemagick libpam-dev - name: Load database schema run: | bin/rails db:setup bin/flatware fan bin/rails db:test:prepare + - name: Cache RSpec persistence file + uses: actions/cache@v4 + with: + path: | + tmp/rspec/examples.txt + key: rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + restore-keys: | + rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}-${{ matrix.ruby-version }} + rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + rspec-persistence-${{ github.head_ref || github.ref_name }} + rspec-persistence-main + rspec-persistence + - run: bin/flatware rspec -r ./spec/flatware_helper.rb - 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: @@ -148,7 +175,7 @@ jobs: test-libvips: name: Libvips tests - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest needs: - build @@ -181,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 @@ -199,8 +226,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' + - '3.3' - '.ruby-version' steps: - uses: actions/checkout@v4 @@ -218,16 +245,16 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg libpam-dev libyaml-dev + additional-system-dependencies: ffmpeg libpam-dev - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' - - run: bin/rspec --tag paperclip_processing + - run: bin/rspec --tag attachment_processing - 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: @@ -268,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 @@ -278,8 +304,8 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.1' - '3.2' + - '3.3' - '.ruby-version' steps: @@ -298,7 +324,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg + additional-system-dependencies: ffmpeg imagemagick - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -384,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 @@ -395,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 @@ -418,7 +443,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg + additional-system-dependencies: ffmpeg imagemagick - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.gitignore b/.gitignore index a70f30f952..a74317bd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ docker-compose.override.yml # Ignore dotenv .local files .env*.local + +# Ignore local-only rspec configuration +.rspec-local diff --git a/.nvmrc b/.nvmrc index cecb936289..744ca17ec0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.15 +22.14 diff --git a/.profile b/.profile deleted file mode 100644 index f4826ea303..0000000000 --- a/.profile +++ /dev/null @@ -1 +0,0 @@ -LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio:/app/.apt/usr/lib/x86_64-linux-gnu/openblas-pthread diff --git a/.rspec b/.rspec index 9a8e706d09..83e16f8044 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,2 @@ --color --require spec_helper ---format Fuubar diff --git a/.rubocop.yml b/.rubocop.yml index 965f56f3e7..bba4282855 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 @@ -26,9 +26,11 @@ inherit_mode: merge: - Exclude -require: +plugins: - rubocop-rails - rubocop-rspec - - rubocop-rspec_rails - rubocop-performance + +require: + - rubocop-rspec_rails - rubocop-capybara 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/strict.yml b/.rubocop/strict.yml index 2222c6d8b9..c2655a1470 100644 --- a/.rubocop/strict.yml +++ b/.rubocop/strict.yml @@ -7,8 +7,13 @@ RSpec/Focus: # Require full spec run on CI Exclude: [] Rails/Output: # Remove any `puts` debugging + inherit_mode: + merge: + - Include Enabled: true Exclude: [] + Include: + - spec/**/*.rb Rails/FindEach: # Using `each` could impact performance, use `find_each` Enabled: true 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 4a86051c42..9c9751775e 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.64.1. +# using RuboCop version 1.72.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,13 +8,13 @@ Lint/NonLocalExitFromIterator: Exclude: - - 'app/helpers/jsonld_helper.rb' + - 'app/helpers/json_ld_helper.rb' # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 90 -# Configuration parameters: CountBlocks, Max. +# Configuration parameters: CountBlocks, CountModifierForms, Max. Metrics/BlockNesting: Exclude: - 'lib/tasks/mastodon.rake' @@ -35,17 +35,13 @@ Rails/OutputSafety: # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - - 'app/lib/redis_configuration.rb' - - 'app/lib/translation_service.rb' - 'config/environments/production.rb' - 'config/initializers/2_limited_federation_mode.rb' - 'config/initializers/3_omniauth.rb' - - 'config/initializers/blacklists.rb' - 'config/initializers/cache_buster.rb' - 'config/initializers/devise.rb' - 'config/initializers/paperclip.rb' - 'config/initializers/vapid.rb' - - 'lib/mastodon/redis_config.rb' - 'lib/tasks/repo.rake' # This cop supports safe autocorrection (--autocorrect). @@ -73,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' @@ -94,7 +81,6 @@ Style/OptionalBooleanParameter: - 'app/services/fetch_resource_service.rb' - 'app/workers/domain_block_worker.rb' - 'app/workers/unfollow_follow_worker.rb' - - 'lib/mastodon/redis_config.rb' # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. @@ -108,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 619b537668..4d9d11cf50 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.3 +3.4.2 diff --git a/Aptfile b/Aptfile index 5e033f1365..06c91d4c7b 100644 --- a/Aptfile +++ b/Aptfile @@ -1,5 +1,5 @@ -ffmpeg -libopenblas0-pthread -libpq-dev -libxdamage1 -libxfixes3 +libidn12 +# for idn-ruby on heroku-24 stack + +# use https://github.com/heroku/heroku-buildpack-activestorage-preview +# in place for ffmpeg and its dependent packages to reduce slag size diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3d96ba4a..51e3c419e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,531 @@ All notable changes to this project will be documented in this file. +## [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. + +### Security + +- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\ + This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared. +- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx)) +- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 and #32241 by @ClearlyClaire) +- Update dependencies + +### Added + +- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610, #31929, #32089, #32085, #32243, #32179 and #32254 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\ + Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\ + This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\ + As part of this, the visual design of the entire notifications feature has been revamped.\ + This feature is intended to eventually replace the existing notifications column, but for this first beta, users will have to enable it in the “Experimental features” section of the notifications column settings.\ + The API is not final yet, but it consists of: + - a new `group_key` attribute to `Notification` entities + - `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/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.\ + You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\ + Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\ + This adds the following REST API endpoints: + + - `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy + - `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications + - `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests + - `GET /api/v1/notifications/requests/:id`: https://docs.joinmastodon.org/methods/notifications/#get-one-request + - `POST /api/v1/notifications/requests/:id/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-request + - `POST /api/v1/notifications/requests/:id/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-request + - `POST /api/v1/notifications/requests/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-multiple-requests + - `POST /api/v1/notifications/requests/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-multiple-requests + - `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged + + In addition, accepting one or more notification requests generates a new streaming event: + + - `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed + +- **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 [`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. +- **Add "system" theme setting (light/dark theme depending on user system preference)** (#29748, #29553, #29795, #29918, #30839, and #30861 by @nshki, @ErikUden, @mjankowski, @renchap, and @vmstan)\ + Add a “system” theme that automatically switch between default dark and light themes depending on the user's system preferences.\ + Also changes the default server theme to this new “system” theme so that automatic theme selection happens even when logged out. +- **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\ + You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\ + This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link +- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, #31900 and #32188 by @Gargron, @mjankowski and @oneiros)\ + This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\ + Articles hosted outside the fediverse can indicate a fediverse author with a meta tag: + ```html + + ``` + On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors \ + Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\ + This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1 +- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\ + In addition to email notifications, also notify users of moderation actions or warnings against them directly within the app, so they are less likely to miss important communication from their moderators.\ + This adds the `moderation_warning` notification type to the REST API and streaming, with a new [`moderation_warning` attribute](https://docs.joinmastodon.org/entities/Notification/#moderation_warning). +- **Add domain information to profiles in web UI** (#29602 by @Gargron)\ + Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation. +- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\ + See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel +- **Add ability to reorder uploaded media before posting in web UI** (#28456 and #32093 by @Gargron) +- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire) +- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron) +- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron) +- Add reblogs and favourites counts to statuses in ActivityPub (#32007 by @Gargron) +- Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm) +- Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\ + This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon +- Add `api_versions` to `/api/v2/instance` (#31354 by @ClearlyClaire)\ + Add API version number to make it easier for clients to detect compatible features going forward.\ + See API documentation at https://docs.joinmastodon.org/entities/Instance/#api-versions +- Add quick links to Administration and Moderation Reports from Web UI (#24838 by @ThisIsMissEm) +- Add link to `/admin/roles` in moderation interface when changing someone's role (#31791 by @ClearlyClaire) +- Add recent audit log entries in federation moderation interface (#27386 by @ThisIsMissEm) +- Add profile setup to onboarding in web UI (#27829, #27876, and #28453 by @Gargron) +- Add prominent share/copy button on profiles in web UI (#27865 and #27889 by @ClearlyClaire and @Gargron) +- Add optional hints for server rules (#29539 and #29758 by @ClearlyClaire and @Gargron)\ + Server rules can now be broken into a short rule name and a longer explanation of the rule.\ + This adds a new [`hint` attribute](https://docs.joinmastodon.org/entities/Rule/#hint) to `Rule` entities in the REST API. +- Add support for PKCE in OAuth flow (#31129 by @ThisIsMissEm) +- Add CDN cache busting on media deletion (#31353 and #31414 by @ClearlyClaire and @tribela) +- Add the OAuth application used in local reports (#30539 by @ThisIsMissEm) +- Add hint to user that other remote statuses may be missing (#26910, #31387, and #31516 by @Gargron, @audiodude, and @renchap) +- Add lang attribute on preview card title (#31303 by @c960657) +- Add check for `Content-Length` in `ResponseWithLimitAdapter` (#31285 by @c960657) +- Add `Accept-Language` header to fetch preview cards in the server's default language (#31232 by @c960657) +- Add support for PKCE Extension in OmniAuth OIDC through the `OIDC_USE_PKCE` environment variable (#31131 by @ThisIsMissEm) +- Add API endpoints for unread notifications count (#31191 by @ClearlyClaire)\ + This adds the following REST API endpoints: + - `GET /api/v1/notifications/unread_count`: https://docs.joinmastodon.org/methods/notifications/#unread-count +- Add `/` keyboard shortcut to focus the search field (#29921 by @ClearlyClaire) +- Add button to view the Hashtag on the instance from Hashtags in Moderation UI (#31533 by @ThisIsMissEm) +- Add list of pending releases directly in mail notifications for version updates (#29436 and #30035 by @ClearlyClaire) +- Add “Appeals” link under “Moderation” navigation category in moderation interface (#31071 by @ThisIsMissEm) +- Add badge on account card in report moderation interface when account is already suspended (#29592 by @ClearlyClaire) +- Add admin comments directly to the `admin/instances` page (#29240 by @tribela) +- Add ability to require approval when users sign up using specific email domains (#28468, #28732, #28607, and #28608 by @ClearlyClaire) +- Add banner for forwarded reports made by remote users about remote content (#27549 by @ClearlyClaire) +- Add support HTML ruby tags in remote posts for east-asian languages (#30897 by @ThisIsMissEm) +- Add link to manage warning presets in admin navigation (#26199 by @vmstan) +- Add volume saving/reuse to video player (#27488 by @thehydrogen) +- Add Elasticsearch index size, ffmpeg and ImageMagick versions to the admin dashboard (#27301, #30710, #31130, and #30845 by @vmstan) +- Add `MASTODON_SIDEKIQ_READY_FILENAME` environment variable to use a file for Sidekiq to signal it is ready to process jobs (#30971 and #30988 by @renchap)\ + In the official Docker image, this is set to `sidekiq_process_has_started_and_will_begin_processing_jobs` so that Sidekiq will touch `tmp/sidekiq_process_has_started_and_will_begin_processing_jobs` to signal readiness. +- Add `S3_RETRY_LIMIT` environment variable to make S3 retries configurable (#23215 by @smiba) +- Add `S3_KEY_PREFIX` environment variable (#30181 by @S0yKaf) +- Add support for multiple `redirect_uris` when creating OAuth 2.0 Applications (#29192 by @ThisIsMissEm) +- Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap) +- Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc) +- Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan) +- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, #30858 and #32104 by @ClearlyClaire, @Gargron, and @mjankowski)\ + Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\ + This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\ + This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future. +- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm) +- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2) +- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix) +- Add OpenTelemetry instrumentation (#30130, #30322, #30353, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\ + See https://docs.joinmastodon.org/admin/config/#otel for documentation +- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\ + This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index +- Add support for CORS to `POST /oauth/revoke` (#31743 by @ClearlyClaire) +- Add redirection back to previous page after site upload deletion (#30141 by @FawazFarid) +- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm) +- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire) +- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm) +- Add the role ID to the badge component (#29707 by @renchap) +- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski) +- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski) +- Add support for specifying custom CA cert for Elasticsearch through `ES_CA_FILE` (#29122 and #29147 by @ClearlyClaire) +- Add groundwork for annual reports for accounts (#28693 by @Gargron)\ + This lays the groundwork for a “year-in-review”/“wrapped” style report for local users, but is currently not in use. +- Add notification email on invalid second authenticator (#28822 by @ClearlyClaire) +- Add date of account deletion in list of accounts in the admin interface (#25640 by @tribela) +- Add new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem) +- Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\ + This makes the S3 batch size configurable through the `S3_BATCH_DELETE_LIMIT` environment variable (defaults to 1000), and adds some retry logic, configurable through the `S3_BATCH_DELETE_RETRY` environment variable (defaults to 3). +- Add VAPID public key to instance serializer (#28006 by @ThisIsMissEm) +- Add support for serving JRD `/.well-known/host-meta.json` in addition to XRD host-meta (#32206 by @c960657) +- Add `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543) +- Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus) +- Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire) +- Add support for invite codes in the registration API (#27805 by @ClearlyClaire) +- Add HTML lang attribute to preview card descriptions (#27503 by @srapilly) +- Add display of relevant account warnings to report action logs (#27425 by @ClearlyClaire) +- Add validation of allowed schemes on preview card URLs (#27485 by @mjankowski) +- Add token introspection without read scope to `/api/v1/apps/verify_credentials` (#27142 by @ThisIsMissEm) +- Add support for cross-origin request to `/nodeinfo/2.0` (#27413 by @palant) +- Add variable delay before link verification of remote account links (#27351 by @ClearlyClaire) +- Add PWA shortcut to `/explore` page (#27235 by @jake-anto) + +### Changed + +- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, #31525, #32153, and #32201 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\ + This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\ + In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state. +- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, #31889 and #32033 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\ + The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\ + As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”. +- **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\ + The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\ + They also have a more modern and consistent design, along with other confirmation modals in the application. +- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @renchap, and @vmstan) +- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron) +- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, #29879, #32073 and #32132 by @c960657, @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\ + All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients. +- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\ + This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\ + In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\ + This change deprecates the `source` attribute in `Suggestion` entities in the REST API, and replaces it with the new [`sources` attribute](https://docs.joinmastodon.org/entities/Suggestion/#sources). +- Change account search algorithm (#30803 by @Gargron) +- **Change streaming server to use its own dependencies and its own docker image** (#24702, #27967, #26850, #28112, #28115, #28137, #28138, #28497, #28548, #30795, #31612, and #31615 by @TheEssem, @ThisIsMissEm, @jippi, @renchap, @timetinytim, and @vmstan)\ + In order to reduce the amount of runtime dependencies, the streaming server has been moved into a separate package and Docker image.\ + The `mastodon` image does not contain the streaming server anymore, as it has been moved to its own `mastodon-streaming` image.\ + Administrators may need to update their setup accordingly. +- Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron) +- Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros) +- Change embedded posts to use web UI (#31766, #32135 and #32271 by @Gargron) +- Change inner borders in media galleries in web UI (#31852 by @Gargron) +- Change design of media attachments and profile media tab in web UI (#31807, #32048, #31967, #32217, #32224 and #32257 by @ClearlyClaire and @Gargron) +- Change labels on thread indicators in web UI (#31806 by @Gargron) +- Change label of "Data export" menu item in settings interface (#32099 by @c960657) +- Change responsive break points on navigation panel in web UI (#32034 by @Gargron) +- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski) +- Change OAuth authorization prompt to not refer to apps as “third-party” (#32005 by @Gargron) +- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire) +- Change zoom icon in web UI (#29683 by @Gargron) +- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap) +- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm) +- Change width of columns in advanced web UI (#31762 by @Gargron) +- Change design of unread conversations in web UI (#31763 by @Gargron) +- Change Web UI to allow viewing and severing relationships with suspended accounts (#27667 by @ClearlyClaire)\ + This also adds a `with_suspended` parameter to `GET /api/v1/accounts/relationships` in the REST API. +- Change preview card image size limit from 2MB to 8MB when using libvips (#31904 by @ClearlyClaire) +- Change avatars border radius (#31390 by @renchap) +- Change counters to be displayed on profile timelines in web UI (#30525 by @Gargron) +- Change disabled buttons color in light mode to make the difference more visible (#30998 by @renchap) +- Change design of people tab on explore in web UI (#30059 by @Gargron) +- Change sidebar text in web UI (#30696 by @Gargron) +- Change "Follow" to "Follow back" and "Mutual" when appropriate in web UI (#28452, #28465, and #31934 by @ClearlyClaire, @Gargron and @renchap) +- Change media to be hidden/blurred by default in report modal (#28522 by @ClearlyClaire) +- Change order of the "muting" and "blocking" list options in “Data Exports” (#26088 by @fixermark) +- Change admin and moderation notes character limit from 500 to 2000 characters (#30288 by @ThisIsMissEm) +- Change mute options to be in dropdown on muted users list in web UI (#30049 and #31315 by @ClearlyClaire and @Gargron) +- Change out-of-band hashtags design in web UI (#29732 by @Gargron) +- Change design of metadata underneath detailed posts in web UI (#29585, #29605, and #29648 by @ClearlyClaire and @Gargron) +- Change action button to be last on profiles in web UI (#29533 and #29923 by @ClearlyClaire and @Gargron) +- Change confirmation prompts in trending moderation interface to be more specific (#19626 by @tribela) +- Change “Trends” moderation menu to “Recommendations & Trends” and move follow recommendations there (#31292 by @ThisIsMissEm) +- Change irrelevant fields in account cleanup settings to be disabled unless automatic cleanup is enabled (#26562 by @c960657) +- Change dropdown menu icon to not be replaced by close icon when open in web UI (#29532 by @Gargron) +- Change back button to always appear in advanced web UI (#29551 and #29669 by @Gargron) +- Change border of active compose field search inputs (#29832 and #29839 by @vmstan) +- Change instances of Nokogiri HTML4 parsing to HTML5 (#31812, #31815, #31813, and #31814 by @flavorjones) +- Change link detection to allow `@` at the end of an URL (#31124 by @adamniedzielski) +- Change User-Agent to use Mastodon as the product, and http.rb as platform details (#31192 by @ClearlyClaire) +- Change layout and wording of the Content Retention server settings page (#27733 by @vmstan) +- Change unconfirmed users to be kept for one week instead of two days (#30285 by @renchap) +- Change maximum page size for Admin Domain Management APIs from 200 to 500 (#31253 by @ThisIsMissEm) +- Change database pool size to default to Sidekiq concurrency settings in Sidekiq processes (#26488 by @sinoru) +- Change alt text to empty string for avatars (#21875 by @jasminjohal) +- Change Docker images to use custom-built libvips and ffmpeg (#30571, #30569, and #31498 by @vmstan) +- Change external links in the admin audit log to plain text or local administration pages (#27139 and #27150 by @ClearlyClaire and @ThisIsMissEm) +- Change YJIT to be enabled when available (#30310 and #27283 by @ClearlyClaire and @mjankowski)\ + Enable Ruby's built-in just-in-time compiler. This improves performances substantially, at the cost of a slightly increased memory usage. +- Change `.env` file loading from deprecated `dotenv-rails` gem to `dotenv` gem (#29173 and #30121 by @mjankowski)\ + This should have no effect except in the unlikely case an environment variable included a newline. +- Change “Panjabi” language name to the more common spelling “Punjabi” (#27117 by @gunchleoc) +- Change encryption of OTP secrets to use ActiveRecord Encryption (#29831, #28325, #30151, #30202, #30340, and #30344 by @ClearlyClaire and @mjankowski)\ + This requires a manual step from administrators of existing servers. Indeed, they need to generate new secrets, which can be done using `bundle exec rails db:encryption:init`.\ + Furthermore, there is a risk that the introduced migration fails if the server was misconfigured in the past. If that happens, the migration error will include the relevant information. +- Change `/api/v1/announcements` to return regular `Status` entities (#26736 by @ClearlyClaire) +- Change imports to convert case-insensitive fields to lowercase (#29739 and #29740 by @ThisIsMissEm) +- Change stats in the admin interface to be inclusive of the full selected range, from beginning of day to end of day (#29416 and #29841 by @mjankowski) +- Change materialized views to be refreshed concurrently to avoid locks (#29015 by @Gargron) +- Change compose form to use server-provided post character and poll options limits (#28928 and #29490 by @ClearlyClaire and @renchap) +- Change streaming server logging from `npmlog` to `pino` and `pino-http` (#27828 by @ThisIsMissEm)\ + This changes the Mastodon streaming server log format, so this might be considered a breaking change if you were parsing the logs. +- Change media “ALT” label to use a specific CSS class (#28777 by @ClearlyClaire) +- Change streaming API host to not be overridden to localhost in development mode (#28557 by @ClearlyClaire) +- Change cookie rotator to use SHA1 digest for new cookies (#27392 by @ClearlyClaire)\ + Note that this requires that no pre-4.2.0 Mastodon web server is running when this code is deployed, as those would not understand the new cookies.\ + Therefore, zero-downtime updates are only supported if you're coming from 4.2.0 or newer. If you want to skip Mastodon 4.2, you will need to completely stop Mastodon services before updating. +- Change preview card deletes to be done using batch method (#28183 by @vmstan) +- Change `img-src` and `media-src` CSP directives to not include `https:` (#28025 and #28561 by @ClearlyClaire) +- Change self-destruct procedure (#26439, #29049, and #29420 by @ClearlyClaire and @zunda)\ + Instead of enqueuing deletion jobs immediately, `tootctl self-destruct` now outputs a value for the `SELF_DESTRUCT` environment variable, which puts a server in self-destruct mode, processing deletions in the background, while giving users access to their export archives. + +### Removed + +- Remove unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski) +- Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski) +- Remove `CacheBuster` default options (#30718 by @mjankowski) +- Remove home marker updates from the Web UI (#22721 by @davbeck)\ + The web interface was unconditionally updating the home marker to the most recent received post, discarding any value set by other clients, thus making the feature unreliable. +- Remove support for Ruby 3.0 (reaching EOL) (#29702 by @mjankowski) +- Remove setting for unfollow confirmation modal (#29373 by @ClearlyClaire)\ + Instead, the unfollow confirmation modal will always be displayed. +- Remove support for Capistrano (#27295 and #30009 by @mjankowski and @renchap) + +### Fixed + +- **Fix link preview cards not always preserving the original URL from the status** (#27312 by @Gargron) +- Fix log out from user menu not working on Safari (#31402 by @renchap) +- Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela) +- Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski) +- Fix error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire) +- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire) +- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron) +- Fix unresolvable mentions sometimes preventing processing incoming posts (#29215 by @tribela and @ClearlyClaire) +- Fix too many requests caused by relationship look-ups in web UI (#32042 by @Gargron) +- Fix links for reblogs in moderation interface (#31979 by @ClearlyClaire) +- Fix the appearance of avatars when they do not load (#31966 and #32270 by @Gargron and @renchap) +- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657) +- Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil) +- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire) +- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77) +- Fix wrapping in dashboard quick access buttons (#32043 by @renchap) +- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski) +- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski) +- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap) +- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath) +- Fix cutoff of instance name in sign-up form (#30598 by @oneiros) +- Fix invalid date searches returning 503 errors (#31526 by @notchairmk) +- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657) +- Fix some components re-rendering spuriously in web UI (#31879 and #31881 by @ClearlyClaire and @Gargron) +- Fix sort order of moderation notes on Reports and Accounts (#31528 by @ThisIsMissEm) +- Fix email language when recipient has no selected locale (#31747 by @ClearlyClaire) +- Fix frequently-used languages not correctly updating in the web UI (#31386 by @c960657) +- Fix `POST /api/v1/statuses` silently ignoring invalid `media_ids` parameter (#31681 by @c960657) +- Fix handling of the `BIND` environment variable in the streaming server (#31624 by @ThisIsMissEm) +- Fix empty `aria-hidden` attribute value in logo resources area (#30570 by @mjankowski) +- Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm) +- Fix right-to-left text in preview cards (#30930 by @ClearlyClaire) +- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski) +- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, #31445, #32091, #32147 and #32137 by @ClearlyClaire, @mjankowski, @valtlai and @vmstan) +- Fix editing description of media uploads with custom thumbnails (#32221 by @ClearlyClaire) +- Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire) +- Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire) +- Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers) +- Fix unneeded requests to blocked domains when receiving relayed signed activities from them (#31161 by @ClearlyClaire) +- Fix logo pushing header buttons out of view on certain conditions in mobile layout (#29787 by @ClearlyClaire) +- Fix notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire) +- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski) +- Fix distracting and confusing always-showing scrollbar track in boost confirmation modal (#31524 by @ClearlyClaire) +- Fix being able to upload more than 4 media attachments in some cases (#29183 by @mashirozx) +- Fix preview card player getting embedded when clicking on the external link button (#29457 by @ClearlyClaire) +- Fix full date display not respecting the locale 12/24h format (#29448 by @renchap) +- Fix filters title and keywords overflow (#29396 by @GeopJr) +- Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon) +- Fix navigation item active highlight for some paths (#32159 by @mjankowski) +- Fix “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen) +- Fix modal container bounds (#29185 by @nico3333fr) +- Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire) +- Fix moderation report updates through `PUT /api/v1/admin/reports/:id` not being logged in the audit log (#29044, #30342, and #31033 by @mjankowski, @tribela, and @vmstan) +- Fix moderation interface allowing to select rule violation when there are no server rules (#31458 by @ThisIsMissEm) +- Fix redirection from paths with url-encoded `@` to their decoded form (#31184 by @timothyjrogers) +- Fix Trending Tags pending review having an unstable sort order (#31473 by @ThisIsMissEm) +- Fix the emoji dropdown button always opening the dropdown instead of behaving like a toggle (#29012 by @jh97uk) +- Fix processing of incoming posts with bearcaps (#26527 by @kmycode) +- Fix support for IPv6 redis connections in streaming (#31229 by @ThisIsMissEm) +- Fix search form re-rendering spuriously in web UI (#28876 by @Gargron) +- Fix `RedownloadMediaWorker` not being called on transient S3 failure (#28714 by @ClearlyClaire) +- Fix ISO code for Canadian French from incorrect `fr-QC` to `fr-CA` (#26015 by @gunchleoc) +- Fix `.opus` file uploads being misidentified by Paperclip (#28580 by @vmstan) +- Fix loading local accounts with extraneous domain part in WebUI (#28559 by @ClearlyClaire) +- Fix destructive actions in dropdowns not using error color in light theme (#28484 by @logicalmoody) +- Fix call to inefficient `delete_matched` cache method in domain blocks (#28374 by @ClearlyClaire) +- Fix status edits not always being streamed to mentioned users (#28324 by @ClearlyClaire) +- Fix onboarding step descriptions being truncated on narrow screens (#28021 by @ClearlyClaire) +- Fix duplicate IDs in relationships and familiar_followers APIs (#27982 by @KevinBongart) +- Fix modal content not being selectable (#27813 by @pajowu) +- Fix Web UI not displaying appropriate explanation when a user hides their follows/followers (#27791 by @ClearlyClaire) +- Fix format-dependent redirects being cached regardless of requested format (#27632 by @ClearlyClaire) +- Fix confusing screen when visiting a confirmation link for an already-confirmed email (#27368 by @ClearlyClaire) +- Fix explore page reloading when you navigate back to it in web UI (#27489 by @Gargron) +- Fix missing redirection from `/home` to `/deck/home` in the advanced interface (#27378 by @Signez) +- 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 + +### Added + +- Add support for incoming `` tag ([mediaformat](https://github.com/mastodon/mastodon/pull/31375)) + +### Changed + +- Change logic of block/mute bypass for mentions from moderators to only apply to visible roles with moderation powers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31271)) + +### Fixed + +- Fix incorrect rate limit on PUT requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31356)) +- Fix presence of `ß` in adjacent word preventing mention and hashtag matching ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31122)) +- Fix processing of webfinger responses with multiple `self` links ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31110)) +- Fix duplicate `orderedItems` in user archive's `outbox.json` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31099)) +- Fix click event handling when clicking outside of an open dropdown menu ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31251)) +- Fix status processing failing halfway when a remote post has a malformed `replies` attribute ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31246)) +- Fix `--verbose` option of `tootctl media remove`, which was previously erroneously removed ([mjankowski](https://github.com/mastodon/mastodon/pull/30536)) +- Fix division by zero on some video/GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30600)) +- Fix Web UI trying to save user settings despite being logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30324)) +- Fix hashtag regexp matching some link anchors ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30190)) +- Fix local account search on LDAP login being case-sensitive ([raucao](https://github.com/mastodon/mastodon/pull/30113)) +- Fix development environment admin account not being auto-approved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29958)) +- Fix report reason selector in moderation interface not unselecting rules when changing category ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29026)) +- Fix already-invalid reports failing to resolve ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29027)) +- Fix OCR when using S3/CDN for assets ([vmstan](https://github.com/mastodon/mastodon/pull/28551)) +- Fix error when encountering malformed `Tag` objects from Kbin ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28235)) +- Fix not all allowed image formats showing in file picker when uploading custom emoji ([june128](https://github.com/mastodon/mastodon/pull/28076)) +- Fix search popout listing unusable search options when logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27918)) +- Fix processing of featured collections lacking an `items` attribute ([tribela](https://github.com/mastodon/mastodon/pull/27581)) +- Fix `mastodon:stats` decoration of stats rake task ([mjankowski](https://github.com/mastodon/mastodon/pull/31104)) + ## [4.2.10] - 2024-07-04 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2271802ca9..f9cbbd8160 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,25 +48,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 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: @@ -74,18 +100,27 @@ 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 7f7eca06da..28a77e460b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -# syntax=docker/dockerfile:1.8 +# 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.3" +ARG RUBY_VERSION="3.4.2" # # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # renovate: datasource=node-version depName=node -ARG NODE_MAJOR_VERSION="20" +ARG NODE_MAJOR_VERSION="22" # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] ARG DEBIAN_VERSION="bookworm" # Node image to use for base image based on combined variables (ex: 20-bookworm-slim) -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 +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,29 +48,32 @@ ARG GID="991" # Apply Mastodon build options based on options above ENV \ -# Apply Mastodon version information + # Apply Mastodon version information MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \ -# Apply Mastodon static files and YJIT options + SOURCE_COMMIT="${SOURCE_COMMIT}" \ + # Apply Mastodon static files and YJIT options RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \ RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \ -# Apply timezone + # Apply timezone TZ=${TZ} ENV \ -# Configure the IP to bind Mastodon to when serving traffic + # Configure the IP to bind Mastodon to when serving traffic BIND="0.0.0.0" \ -# Use production settings for Yarn, Node and related nodejs based tools + # Use production settings for Yarn, Node and related nodejs based tools NODE_ENV="production" \ -# Use production settings for Ruby on Rails + # Use production settings for Ruby on Rails RAILS_ENV="production" \ -# Add Ruby and Mastodon installation to the PATH + # Add Ruby and Mastodon installation to the PATH DEBIAN_FRONTEND="noninteractive" \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ -# Optimize jemalloc 5.x performance + # Optimize jemalloc 5.x performance MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \ -# Enable libvips, should not be changed - MASTODON_USE_LIBVIPS=true + # 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 + MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs # Set default shell used for running commands SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"] @@ -77,14 +83,14 @@ ARG TARGETPLATFORM RUN echo "Target platform is $TARGETPLATFORM" RUN \ -# Remove automatic apt cache Docker cleanup scripts + # Remove automatic apt cache Docker cleanup scripts rm -f /etc/apt/apt.conf.d/docker-clean; \ -# Sets timezone + # Sets timezone echo "${TZ}" > /etc/localtime; \ -# Creates mastodon user/group and sets home directory + # Creates mastodon user/group and sets home directory groupadd -g "${GID}" mastodon; \ useradd -l -u "${UID}" -g "${GID}" -m -d /opt/mastodon mastodon; \ -# Creates /mastodon symlink to /opt/mastodon + # Creates /mastodon symlink to /opt/mastodon ln -s /opt/mastodon /mastodon; # Set /opt/mastodon as working directory @@ -92,28 +98,28 @@ WORKDIR /opt/mastodon # hadolint ignore=DL3008,DL3005 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Apt update & upgrade to check for security updates to Debian image + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Apt update & upgrade to check for security updates to Debian image apt-get update; \ apt-get dist-upgrade -yq; \ -# Install jemalloc, curl and other necessary components + # Install jemalloc, curl and other necessary components apt-get install -y --no-install-recommends \ - curl \ - file \ - libjemalloc2 \ - patchelf \ - procps \ - tini \ - tzdata \ - wget \ + curl \ + file \ + libjemalloc2 \ + patchelf \ + procps \ + tini \ + tzdata \ + wget \ ; \ -# Patch Ruby to use jemalloc + # Patch Ruby to use jemalloc patchelf --add-needed libjemalloc.so.2 /usr/local/bin/ruby; \ -# Discard patchelf after use + # Discard patchelf after use apt-get purge -y \ - patchelf \ + patchelf \ ; # Create temporary build layer from base image @@ -130,56 +136,57 @@ 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 \ + libimagequant-dev \ + libjpeg62-turbo-dev \ + liblcms2-dev \ + liborc-dev \ + libspng-dev \ + libtiff-dev \ + libwebp-dev \ # ffmpeg components - libdav1d-dev \ - liblzma-dev \ - libmp3lame-dev \ - libopus-dev \ - libsnappy-dev \ - libvorbis-dev \ - libvpx-dev \ - libx264-dev \ - libx265-dev \ + libdav1d-dev \ + liblzma-dev \ + libmp3lame-dev \ + libopus-dev \ + libsnappy-dev \ + libvorbis-dev \ + libvpx-dev \ + libx264-dev \ + libx265-dev \ ; RUN \ -# Configure Corepack + # Configure Corepack rm /usr/local/bin/yarn*; \ corepack enable; \ corepack prepare --activate; @@ -189,16 +196,19 @@ 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.2 +ARG VIPS_VERSION=8.16.0 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download WORKDIR /usr/local/libvips/src +# Download and extract libvips source code +ADD ${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz /usr/local/libvips/src/ +RUN tar xf vips-${VIPS_VERSION}.tar.xz; +WORKDIR /usr/local/libvips/src/vips-${VIPS_VERSION} + +# Configure and compile libvips RUN \ - curl -sSL -o vips-${VIPS_VERSION}.tar.xz ${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz; \ - tar xf vips-${VIPS_VERSION}.tar.xz; \ - cd vips-${VIPS_VERSION}; \ meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false; \ cd build; \ ninja; \ @@ -209,39 +219,42 @@ FROM build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg -ARG FFMPEG_VERSION=7.0.1 +ARG FFMPEG_VERSION=7.1 # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"] ARG FFMPEG_URL=https://ffmpeg.org/releases WORKDIR /usr/local/ffmpeg/src +# Download and extract ffmpeg source code +ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/ +RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; +WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION} + +# Configure and compile ffmpeg RUN \ - curl -sSL -o ffmpeg-${FFMPEG_VERSION}.tar.xz ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz; \ - tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; \ - cd ffmpeg-${FFMPEG_VERSION}; \ ./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; @@ -255,17 +268,17 @@ ARG TARGETPLATFORM COPY Gemfile* /opt/mastodon/ RUN \ -# Mount Ruby Gem caches ---mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ -# Configure bundle to prevent changes to Gemfile and Gemfile.lock + # Mount Ruby Gem caches + --mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ + # Configure bundle to prevent changes to Gemfile and Gemfile.lock bundle config set --global frozen "true"; \ -# Configure bundle to not cache downloaded Gems + # Configure bundle to not cache downloaded Gems bundle config set --global cache_all "false"; \ -# Configure bundle to only process production Gems + # Configure bundle to only process production Gems bundle config set --local without "development test"; \ -# Configure bundle to not warn about root user + # Configure bundle to not warn about root user bundle config set silence_root_warning "true"; \ -# Download and install required Gems + # Download and install required Gems bundle install -j"$(nproc)"; # Create temporary node specific build layer from build layer @@ -280,9 +293,9 @@ COPY .yarn /opt/mastodon/.yarn # hadolint ignore=DL3008 RUN \ ---mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ ---mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ -# Install Node packages + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Install Node packages yarn workspaces focus --production @mastodon/mastodon; # Create temporary assets build layer from build layer @@ -303,10 +316,10 @@ ARG TARGETPLATFORM RUN \ ldconfig; \ -# Use Ruby on Rails to create Mastodon assets + # Use Ruby on Rails to create Mastodon assets SECRET_KEY_BASE_DUMMY=1 \ bundle exec rails assets:precompile; \ -# Cleanup temporary files + # Cleanup temporary files rm -fr /opt/mastodon/tmp; # Prep final Mastodon Ruby layer @@ -316,49 +329,49 @@ ARG TARGETPLATFORM # hadolint ignore=DL3008 RUN \ -# Mount Apt cache and lib directories from Docker buildx caches ---mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ ---mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ -# Mount Corepack and Yarn caches from Docker buildx caches ---mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ ---mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ -# Apt update install non-dev versions of necessary components + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Mount Corepack and Yarn caches from Docker buildx caches + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Apt update install non-dev versions of necessary components apt-get install -y --no-install-recommends \ - libexpat1 \ - libglib2.0-0 \ - libicu72 \ - libidn12 \ - libpq5 \ - libreadline8 \ - libssl3 \ - libyaml-0-2 \ + libexpat1 \ + libglib2.0-0 \ + libicu72 \ + libidn12 \ + libpq5 \ + libreadline8 \ + libssl3 \ + libyaml-0-2 \ # libvips components - libcgif0 \ - libexif12 \ - libheif1 \ - libimagequant0 \ - libjpeg62-turbo \ - liblcms2-2 \ - liborc-0.4-0 \ - libspng0 \ - libtiff6 \ - libwebp7 \ - libwebpdemux2 \ - libwebpmux3 \ + libcgif0 \ + libexif12 \ + libheif1 \ + libimagequant0 \ + libjpeg62-turbo \ + liblcms2-2 \ + liborc-0.4-0 \ + libspng0 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ # ffmpeg components - libdav1d6 \ - libmp3lame0 \ - libopencore-amrnb0 \ - libopencore-amrwb0 \ - libopus0 \ - libsnappy1v5 \ - libtheora0 \ - libvorbis0a \ - libvorbisenc2 \ - libvorbisfile3 \ - libvpx7 \ - libx264-164 \ - libx265-199 \ + libdav1d6 \ + libmp3lame0 \ + libopencore-amrnb0 \ + libopencore-amrwb0 \ + libopus0 \ + libsnappy1v5 \ + libtheora0 \ + libvorbis0a \ + libvorbisenc2 \ + libvorbisfile3 \ + libvpx7 \ + libx264-164 \ + libx265-199 \ ; # Copy Mastodon sources into final layer @@ -378,7 +391,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; @@ -388,10 +401,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 ef52d50cac..9f8c69267a 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.4.0' +gem 'fog-core', '<= 2.6.0' gem 'fog-openstack', '~> 1.0', require: false +gem 'jd-paperclip-azure', '~> 3.0', require: false gem 'kt-paperclip', '~> 7.2' -gem 'md-paperclip-azure', '~> 2.2', require: false gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' gem 'bootsnap', '~> 1.18.0', require: false -gem 'browser', '< 6' # https://github.com/fnando/browser/issues/543 +gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' gem 'devise', '~> 4.9' @@ -47,14 +47,14 @@ gem 'color_diff', '~> 0.1' gem 'csv', '~> 3.2' gem 'discard', '~> 1.2' gem 'doorkeeper', '~> 5.6' -gem 'ed25519', '~> 1.3' +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,9 +62,9 @@ gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' -gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' +gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar' +gem 'mutex_m' gem 'nokogiri', '~> 1.15' -gem 'nsa' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' @@ -73,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' @@ -88,35 +88,37 @@ gem 'sidekiq-unique-jobs', '~> 7.1' gem 'simple_form', '~> 5.2' gem 'simple-navigation', '~> 4.4' gem 'stoplight', '~> 4.1' -gem 'strong_migrations', '1.8.0' +gem 'strong_migrations' gem 'tty-prompt', '~> 0.23', require: false 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.2.5' +gem 'prometheus_exporter', '~> 2.2', require: false + +gem 'opentelemetry-api', '~> 1.4.0' group :opentelemetry do - gem 'opentelemetry-exporter-otlp', '~> 0.28.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.27.1', require: false - gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.31.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.29.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 @@ -125,10 +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 - - # RSpec progress bar formatter - gem 'fuubar', '~> 2.5' + gem 'rspec-github', '~> 3.0', require: false # RSpec helpers for email specs gem 'email_spec' @@ -146,16 +145,15 @@ 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', '~> 4.0' + gem 'json-schema', '~> 5.0' # Test harness fo rack components gem 'rack-test', '~> 2.1' - # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false + gem 'shoulda-matchers' + + # Coverage formatter for RSpec gem 'simplecov', '~> 0.22', require: false gem 'simplecov-lcov', '~> 0.8', require: false @@ -173,7 +171,7 @@ group :development do 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' @@ -184,7 +182,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 @@ -196,7 +194,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' @@ -208,10 +206,10 @@ 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', '~> 6.0' + gem 'rspec-rails', '~> 7.0' end group :production do @@ -223,7 +221,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 eb6720e454..3e97c94d35 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,150 +1,139 @@ 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.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + actioncable (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.3.4) - actionpack (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) + mail (>= 2.8.0) + actionmailer (8.0.1) + actionpack (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activesupport (= 8.0.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.3.4) - actionview (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionpack (8.0.1) + actionview (= 8.0.1) + activesupport (= 8.0.1) 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.3.4) - actionpack (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + useragent (~> 0.16) + actiontext (8.0.1) + actionpack (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (8.0.1) + activesupport (= 8.0.1) 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.3.4) - activesupport (= 7.1.3.4) + activejob (8.0.1) + activesupport (= 8.0.1) globalid (>= 0.3.6) - activemodel (7.1.3.4) - activesupport (= 7.1.3.4) - activerecord (7.1.3.4) - activemodel (= 7.1.3.4) - activesupport (= 7.1.3.4) + activemodel (8.0.1) + activesupport (= 8.0.1) + activerecord (8.0.1) + activemodel (= 8.0.1) + activesupport (= 8.0.1) timeout (>= 0.4.0) - activestorage (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activesupport (= 7.1.3.4) + activestorage (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activesupport (= 8.0.1) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (8.0.1) 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.950.0) - aws-sdk-core (3.201.0) + aws-partitions (1.1032.0) + aws-sdk-core (3.214.1) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.88.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-kms (1.96.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.156.0) - aws-sdk-core (~> 3, >= 3.201.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.8.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.7) - bootsnap (1.18.3) + blurhash (0.1.8) + bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.1.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) builder (3.3.0) - bundler-audit (0.9.1) + bundler-audit (0.9.2) bundler (>= 1.2.0, < 3) thor (~> 1.0) capybara (3.40.0) @@ -159,33 +148,35 @@ GEM case_transform (0.2) activesupport cbor (0.5.9.8) - charlock_holmes (0.7.8) + charlock_holmes (0.7.9) chewy (7.6.0) activesupport (>= 5.2) elasticsearch (>= 7.14.0, < 8) elasticsearch-dsl + childprocess (5.1.0) + logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.3.3) - connection_pool (2.4.1) - cose (1.3.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) + cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) crack (1.0.0) bigdecimal rexml crass (1.0.6) - css_parser (1.17.1) + css_parser (1.21.0) addressable - csv (3.3.0) - database_cleaner-active_record (2.1.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) @@ -195,103 +186,84 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (5.1.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) - docile (1.4.0) + 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.2) + dotenv (3.1.7) drb (2.2.1) - ed25519 (1.3.0) - elasticsearch (7.17.10) - elasticsearch-api (= 7.17.10) - elasticsearch-transport (= 7.17.10) - elasticsearch-api (7.17.10) + elasticsearch (7.17.11) + elasticsearch-api (= 7.17.11) + elasticsearch-transport (= 7.17.11) + elasticsearch-api (7.17.11) multi_json elasticsearch-dsl (0.1.10) - elasticsearch-transport (7.17.10) + elasticsearch-transport (7.17.11) + base64 faraday (>= 1, < 3) multi_json - email_spec (2.2.2) + email_spec (2.3.0) htmlentities (~> 4.3.3) - launchy (~> 2.1) + launchy (>= 2.1, < 4.0) mail (~> 2.7) - erubi (1.13.0) + erubi (1.13.1) et-orbi (1.2.11) tzinfo - excon (0.110.0) + excon (1.2.3) fabrication (2.31.0) - faker (3.4.1) + 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.1) - 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-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.2) + flatware (2.3.4) + drb thor (< 2.0) - flatware-rspec (2.3.2) - flatware (= 2.3.2) + flatware-rspec (2.3.4) + flatware (= 2.3.4) rspec (>= 3.6) - fog-core (2.4.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) - fugit (1.10.1) - et-orbi (~> 1, >= 1.2.7) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) - fuubar (2.5.1) - rspec-core (~> 3.0) - ruby-progressbar (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (3.25.3) - googleapis-common-protos-types (1.14.0) - google-protobuf (~> 3.18) + google-protobuf (3.25.5) + googleapis-common-protos-types (1.15.0) + google-protobuf (>= 3.18, < 5.a) haml (6.3.0) temple (>= 0.8.2) thor @@ -301,17 +273,18 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.58.0) + haml_lint (0.61.0) haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.1.0) + hashdiff (1.1.2) hashie (5.0.0) hcaptcha (7.1.0) json - highline (3.0.1) + highline (3.1.2) + reline hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) @@ -321,7 +294,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) @@ -329,7 +302,7 @@ GEM httplog (1.7.0) rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.14.5) + i18n (1.14.7) concurrent-ruby (~> 1.0) i18n-tasks (1.0.14) activesupport (>= 4.0.2) @@ -342,35 +315,43 @@ GEM rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) idn-ruby (0.1.5) - inline_svg (1.9.0) + inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.7.2) - irb (1.13.2) + 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.1) json-canonicalization (1.0.0) json-jwt (1.15.3.1) activesupport (>= 4.2) aes_key_wrap bindata httpclient - json-ld (3.3.1) + json-ld (3.3.2) htmlentities (~> 4.3) json-canonicalization (~> 1.0) link_header (~> 0.0, >= 0.0.8) multi_json (~> 1.15) rack (>= 2.2, < 4) rdf (~> 3.3) - json-ld-preloaded (3.3.0) + rexml (~> 3.2) + json-ld-preloaded (3.3.1) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (4.3.0) - addressable (>= 2.8) + 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) @@ -389,9 +370,11 @@ GEM marcel (~> 1.0.1) mime-types terrapin (>= 0.6.0, < 2.0) - language_server-protocol (3.17.0.3) - launchy (2.5.2) + 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) @@ -400,16 +383,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.0) + 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) @@ -421,26 +405,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.0.2) - mime-types (3.5.2) + memory_profiler (1.1.0) + mime-types (3.6.0) + logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.0604) + mime-types-data (3.2025.0220) mini_mime (1.1.5) - mini_portile2 (2.8.7) - minitest (5.24.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.0) - 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.12) + net-imap (0.5.6) date net-protocol net-ldap (0.19.0) @@ -448,19 +426,15 @@ 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.6) + nio4r (2.7.4) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nsa (0.3.0) - activesupport (>= 4.2, < 7.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.16.4) + oj (3.16.10) bigdecimal (>= 3.0) + ostruct (>= 0.2) omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -469,12 +443,12 @@ GEM addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) - omniauth-rails_csrf_protection (1.0.1) + omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.1.0) - omniauth (~> 2.0) - ruby-saml (~> 1.12) + omniauth-saml (2.2.1) + omniauth (~> 2.1) + ruby-saml (~> 1.17) omniauth_openid_connect (0.6.1) omniauth (>= 1.9, < 3) openid_connect (~> 1.1) @@ -489,132 +463,148 @@ GEM validate_email validate_url webfinger (~> 1.2) - openssl (3.2.0) + openssl (3.3.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.2.5) - opentelemetry-common (0.20.1) + opentelemetry-api (1.4.0) + opentelemetry-common (0.21.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.28.0) + opentelemetry-exporter-otlp (0.29.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.2) opentelemetry-semantic_conventions - opentelemetry-helpers-sql-obfuscation (0.1.0) - opentelemetry-common (~> 0.20) - opentelemetry-instrumentation-action_mailer (0.1.0) + opentelemetry-helpers-sql-obfuscation (0.3.0) + opentelemetry-common (~> 0.21) + 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.0) + 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.2) + 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.1) + 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.7.2) + 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.0) + opentelemetry-api (~> 1.4.0) + 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.22.1) - opentelemetry-instrumentation-base (0.22.3) + 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.3) + opentelemetry-instrumentation-concurrent_ruby (0.22.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.3) + 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.5) + 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.3) + 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.6) + 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.6) + 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.27.3) + 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.24.5) + 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.31.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rails (0.36.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.1.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.7.0) - opentelemetry-instrumentation-active_support (~> 0.6.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-redis (0.25.6) + 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.6) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-sidekiq (0.26.0) opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-registry (0.3.1) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.4.1) + opentelemetry-sdk (1.7.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.10.0) + opentelemetry-semantic_conventions (1.10.1) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) - ox (2.14.18) - parallel (1.25.1) - parser (3.3.3.0) + ostruct (0.6.1) + ox (2.14.22) + bigdecimal (>= 3.0) + parallel (1.26.3) + parser (3.3.7.1) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.6) - pghero (3.5.0) - activerecord (>= 6) - premailer (1.23.0) + pg (1.5.9) + pghero (3.6.1) + activerecord (>= 6.1) + pp (0.6.2) + prettyprint + premailer (1.27.0) addressable - css_parser (>= 1.12.0) + css_parser (>= 1.19.0) htmlentities (>= 4.0.0) premailer-rails (1.12.0) actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) - propshaft (0.9.0) + 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.0) - puma (6.4.2) + public_suffix (6.0.1) + puma (6.6.0) nio4r (~> 2.0) - pundit (2.3.2) + pundit (2.4.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.8.0) - rack (2.2.9) + racc (1.8.1) + rack (2.2.11) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -632,55 +622,52 @@ 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.3.4) - actioncable (= 7.1.3.4) - actionmailbox (= 7.1.3.4) - actionmailer (= 7.1.3.4) - actionpack (= 7.1.3.4) - actiontext (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activemodel (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + rails (8.0.1) + actioncable (= 8.0.1) + actionmailbox (= 8.0.1) + actionmailer (= 8.0.1) + actionpack (= 8.0.1) + actiontext (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activemodel (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) bundler (>= 1.15.0) - railties (= 7.1.3.4) - rails-controller-testing (1.0.5) - actionpack (>= 5.0.1.rc1) - actionview (>= 5.0.1.rc1) - activesupport (>= 5.0.1.rc1) + railties (= 8.0.1) 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.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) - irb + railties (>= 8.0.0, < 9) + railties (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdf (3.3.1) + rdf (3.3.2) bcp47_spec (~> 0.2) + bigdecimal (~> 3.1, >= 3.1.5) 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) redis (4.8.1) @@ -688,18 +675,17 @@ GEM redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.9.2) - reline (0.5.9) + 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.1) - strscan + rexml (3.4.1) rotp (6.3.0) - rouge (4.2.1) + rouge (4.5.1) rpam2 (4.0.2) rqrcode (2.2.0) chunky_png (~> 1.0) @@ -709,20 +695,20 @@ GEM rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.0) + rspec-core (3.13.3) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + 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 (6.1.3) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) + rspec-rails (7.1.1) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) rspec-core (~> 3.13) rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) @@ -732,71 +718,77 @@ GEM rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) - rspec-support (3.13.1) - rubocop (1.64.1) + rspec-support (3.13.2) + rubocop (1.72.2) 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 (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 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.31.3) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) - rubocop-performance (1.21.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.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.2) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.0.2) - rubocop (~> 1.61) + 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.30.0) rubocop (~> 1.61) rubocop-rspec (~> 3, >= 3.0.1) - ruby-prof (1.7.0) + ruby-prof (1.7.1) ruby-progressbar (1.13.0) - ruby-saml (1.16.0) + ruby-saml (1.17.0) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.1) + ruby-vips (2.2.3) ffi (~> 1.12) - ruby2_keywords (0.0.5) - rubyzip (2.3.2) - rufus-scheduler (3.9.1) - fugit (~> 1.1, >= 1.1.6) + logger + 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.1) + 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.22.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) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (5.0.3) + sidekiq-scheduler (5.0.6) rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) - tilt (>= 1.4.0) + tilt (>= 1.4.0, < 3) sidekiq-unique-jobs (7.1.33) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) @@ -812,32 +804,30 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) - stackprof (0.2.26) - statsd-ruby (1.5.0) - stoplight (4.1.0) + stackprof (0.2.27) + stoplight (4.1.1) redlock (~> 1.0) - stringio (3.1.1) - strong_migrations (1.8.0) - activerecord (>= 5.2) - strscan (3.1.0) + stringio (3.1.4) + strong_migrations (2.2.0) + activerecord (>= 7) swd (1.3.0) activesupport (>= 3) attr_required (>= 0.0.5) httpclient (>= 2.4) 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.3.3.1) - thor (1.3.1) - tilt (2.3.0) - timeout (0.4.1) - tpm-key_attestation (0.12.0) + test-prof (1.4.4) + thor (1.3.2) + 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) @@ -856,13 +846,16 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2024.1) + 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.5.0) - uri (0.13.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.2) + useragent (0.16.11) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -871,19 +864,18 @@ GEM 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) + tpm-key_attestation (~> 0.14.0) webfinger (1.2.0) activesupport httpclient (>= 2.4) - webmock (3.23.1) + webmock (3.25.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -892,16 +884,17 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webrick (1.8.1) - websocket (1.2.10) - websocket-driver (0.7.6) + webrick (1.9.1) + websocket (1.2.11) + 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.16) + zeitwerk (2.7.2) PLATFORMS ruby @@ -909,14 +902,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) @@ -935,16 +928,15 @@ DEPENDENCIES discard (~> 1.2) doorkeeper (~> 5.6) dotenv - ed25519 (~> 1.3) email_spec fabrication (~> 2.30) faker (~> 3.2) + faraday-httpclient fast_blank (~> 1.0) fastimage flatware-rspec - fog-core (<= 2.4.0) + fog-core (<= 2.6.0) fog-openstack (~> 1.0) - fuubar (~> 2.5) haml-rails (~> 2.0) haml_lint hcaptcha (~> 7.1) @@ -958,9 +950,10 @@ DEPENDENCIES idn-ruby inline_svg irb (~> 1.8) + jd-paperclip-azure (~> 3.0) json-ld json-ld-preloaded (~> 3.2) - json-schema (~> 4.0) + json-schema (~> 5.0) kaminari (~> 1.2) kt-paperclip (~> 7.2) letter_opener (~> 1.8) @@ -969,40 +962,40 @@ DEPENDENCIES lograge (~> 0.12) mail (~> 2.8) mario-redis-lock (~> 1.2) - md-paperclip-azure (~> 2.2) memory_profiler - mime-types (~> 3.5.0) - net-http (~> 0.4.0) + mime-types (~> 3.6.0) + mutex_m + net-http (~> 0.6.0) net-ldap (~> 0.18) nokogiri (~> 1.15) - nsa oj (~> 3.14) omniauth (~> 2.0) 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.2.5) - opentelemetry-exporter-otlp (~> 0.28.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.27.1) - opentelemetry-instrumentation-rack (~> 0.24.1) - opentelemetry-instrumentation-rails (~> 0.31.0) - opentelemetry-instrumentation-redis (~> 0.25.3) - opentelemetry-instrumentation-sidekiq (~> 0.25.2) + opentelemetry-api (~> 1.4.0) + opentelemetry-exporter-otlp (~> 0.29.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) @@ -1011,16 +1004,15 @@ 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-rails (~> 6.0) + rspec-github (~> 3.0) + rspec-rails (~> 7.0) rspec-sidekiq (~> 5.0) rubocop rubocop-capybara @@ -1032,9 +1024,10 @@ 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 sidekiq (~> 6.5) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 5.0) @@ -1045,7 +1038,7 @@ DEPENDENCIES simplecov-lcov (~> 0.8) stackprof stoplight (~> 4.1) - strong_migrations (= 1.8.0) + strong_migrations test-prof thor (~> 1.2) tty-prompt (~> 0.23) @@ -1058,7 +1051,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.3.2p78 + ruby 3.4.1p0 BUNDLED WITH - 2.5.11 + 2.6.5 diff --git a/Procfile b/Procfile index d15c835b86..f033fd36c6 100644 --- a/Procfile +++ b/Procfile @@ -11,4 +11,4 @@ worker: bundle exec sidekiq # # and let the main app use the separate app: # -# heroku config:set STREAMING_API_BASE_URL=wss://.herokuapp.com -a +# heroku config:set STREAMING_API_BASE_URL=wss://.herokuapp.com -a diff --git a/README.md b/README.md index 0fdc8d378c..f1c6cd4322 100644 --- a/README.md +++ b/README.md @@ -14,27 +14,27 @@ Mastodon Glitch Edition is a fork of [Mastodon](https://github.com/mastodon/mast --- -

- - - 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) @@ -53,25 +53,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 @@ -85,90 +75,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/SECURITY.md b/SECURITY.md index 156954ce02..43ab4454c4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through ## Supported Versions -| Version | Supported | -| ------- | --------- | -| 4.2.x | Yes | -| 4.1.x | Yes | -| < 4.1 | No | +| Version | Supported | +| ------- | ---------------- | +| 4.3.x | Yes | +| 4.2.x | Yes | +| 4.1.x | Until 2025-04-08 | +| < 4.1 | No | diff --git a/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.json b/app.json index 4f05a64f51..5e5a3dc1e7 100644 --- a/app.json +++ b/app.json @@ -90,9 +90,15 @@ } }, "buildpacks": [ + { + "url": "https://github.com/heroku/heroku-buildpack-activestorage-preview" + }, { "url": "https://github.com/heroku/heroku-buildpack-apt" }, + { + "url": "heroku/nodejs" + }, { "url": "heroku/ruby" } @@ -100,5 +106,6 @@ "scripts": { "postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed" }, - "addons": ["heroku-postgresql", "heroku-redis"] + "addons": ["heroku-postgresql", "heroku-redis"], + "stack": "heroku-24" } diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb deleted file mode 100644 index 480baaf2bc..0000000000 --- a/app/controllers/activitypub/claims_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::ClaimsController < ActivityPub::BaseController - skip_before_action :authenticate_user! - - before_action :require_account_signature! - before_action :set_claim_result - - def create - render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer - end - - private - - def set_claim_result - @claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id]) - end -end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 15985c7f65..adc5e93513 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -22,8 +22,6 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) } when 'tags' @items = for_signed_account { @account.featured_tags } - when 'devices' - @items = @account.devices else not_found end @@ -31,7 +29,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_size case params[:id] - when 'featured', 'devices', 'tags' + when 'featured', 'tags' @size = @items.size else not_found @@ -42,7 +40,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController case params[:id] when 'featured' @type = :ordered - when 'devices', 'tags' + when 'tags' @type = :unordered else not_found @@ -51,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/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb new file mode 100644 index 0000000000..4aa6a4a771 --- /dev/null +++ b/app/controllers/activitypub/likes_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::LikesController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_status + + def index + expires_in 0, public: @status.distributable? && public_fetch_mode? + render json: likes_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def likes_collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_status_likes_url(@account, @status), + type: :unordered, + size: @status.favourites_count + ) + end +end 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/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 11aac48c9c..0a19275d38 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -12,7 +12,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController before_action :set_replies def index - expires_in 0, public: public_fetch_mode? + expires_in 0, public: @status.distributable? && public_fetch_mode? render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true end diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb new file mode 100644 index 0000000000..65b4a5b383 --- /dev/null +++ b/app/controllers/activitypub/shares_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::SharesController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_status + + def index + expires_in 0, public: @status.distributable? && public_fetch_mode? + render json: shares_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def shares_collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_status_shares_url(@account, @status), + type: :unordered, + size: @status.reblogs_count + ) + end +end 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 8b6c1a4454..7f65ced517 100644 --- a/app/controllers/admin/account_moderation_notes_controller.rb +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -13,7 +13,7 @@ module Admin redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg') else @account = @account_moderation_note.target_account - @moderation_notes = @account.targeted_moderation_notes.latest + @moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account) @warnings = @account.strikes.custom.latest render 'admin/accounts/show' @@ -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 9beb8fde6b..10391aa3e2 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -33,7 +33,7 @@ module Admin @deletion_request = @account.deletion_request @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) - @moderation_notes = @account.targeted_moderation_notes.latest + @moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account) @warnings = @account.strikes.includes(:target_account, :account, :appeal).latest @domain_block = DomainBlock.rule_for(@account.domain) end @@ -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_controller.rb b/app/controllers/admin/announcements_controller.rb index 8f9708183a..eaf84aab25 100644 --- a/app/controllers/admin/announcements_controller.rb +++ b/app/controllers/admin/announcements_controller.rb @@ -6,6 +6,7 @@ class Admin::AnnouncementsController < Admin::BaseController def index authorize :announcement, :index? + @published_announcements_count = Announcement.published.async_count end def new @@ -83,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 4b5afbe157..14338dd293 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -7,19 +7,14 @@ module Admin layout 'admin' - before_action :set_body_classes - before_action :set_cache_headers + before_action :set_referrer_policy_header after_action :verify_authorized private - def set_body_classes - @body_classes = 'admin' - end - - 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 431dc1524f..fbef61810d 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -47,7 +47,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 @@ -77,7 +78,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/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 3a6df662ea..5b0867dcfb 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -7,12 +7,12 @@ module Admin def index authorize :dashboard, :index? + @pending_appeals_count = Appeal.pending.async_count + @pending_reports_count = Report.unresolved.async_count + @pending_tags_count = Tag.pending_review.async_count + @pending_users_count = User.pending.async_count @system_checks = Admin::SystemCheck.perform(current_user) @time_period = (29.days.ago.to_date...Time.now.utc.to_date) - @pending_users_count = User.pending.count - @pending_reports_count = Report.unresolved.count - @pending_tags_count = Tag.pending_review.count - @pending_appeals_count = Appeal.pending.count end end end diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb index 5e342409b0..0c41553676 100644 --- a/app/controllers/admin/disputes/appeals_controller.rb +++ b/app/controllers/admin/disputes/appeals_controller.rb @@ -6,6 +6,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController def index authorize :appeal, :index? + @pending_appeals_count = Appeal.pending.async_count @appeals = filtered_appeals.page(params[:page]) end diff --git a/app/controllers/admin/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 a6997b62f7..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,6 +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(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 b5f04a1caa..10dbe846e4 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -21,7 +21,7 @@ module Admin redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg') else - @report_notes = @report.notes.includes(:account).order(id: :desc) + @report_notes = @report.notes.chronological.includes(:account) @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new @statuses = @report.statuses.with_includes @@ -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/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 00d200d7c8..aa877f1448 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -13,7 +13,7 @@ module Admin authorize @report, :show? @report_note = @report.notes.new - @report_notes = @report.notes.includes(:account).order(id: :desc) + @report_notes = @report.notes.chronological.includes(:account) @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new @statuses = @report.statuses.with_includes 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 4f727c398a..a7bfd64794 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -2,7 +2,15 @@ module Admin class TagsController < BaseController - before_action :set_tag + before_action :set_tag, except: [:index] + + PER_PAGE = 20 + + def index + authorize :tag, :index? + + @tags = filtered_tags.page(params[:page]).per(PER_PAGE) + end def show authorize @tag, :show? @@ -29,7 +37,16 @@ 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 + TagFilter.new(filter_params.with_defaults(order: 'newest')).results + end + + def filter_params + params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) end end end 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..02cb05946f --- /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) + end + + def current_terms_of_service + TermsOfService.live.first + end + + def resource_params + params + .expect(terms_of_service: [:text, :changelog]) + 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..f70bfd2071 --- /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.live.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 768b79f8db..5a650d5d8c 100644 --- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll def index authorize :preview_card_provider, :review? + @pending_preview_card_providers_count = PreviewCardProvider.unreviewed.async_count @preview_card_providers = filtered_preview_card_providers.page(params[:page]) @form = Trends::PreviewCardProviderBatch.new end @@ -30,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 f5946448ae..1ccd740686 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController def index authorize :tag, :review? + @pending_tags_count = Tag.pending_review.async_count @tags = filtered_tags.page(params[:page]) @form = Trends::TagBatch.new end @@ -30,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/base_controller.rb b/app/controllers/api/base_controller.rb index c1a5e43f88..0980e0ebbc 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -30,10 +30,10 @@ class Api::BaseController < ApplicationController protected - def limit_param(default_limit) + def limit_param(default_limit, max_limit = nil) return default_limit unless params[:limit] - [params[:limit].to_i.abs, default_limit * 2].min + [params[:limit].to_i.abs, max_limit || (default_limit * 2)].min end def params_slice(*keys) diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 66da65beda..b7f22824a7 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -7,7 +7,7 @@ class Api::OEmbedController < Api::BaseController before_action :require_public_status! def show - render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default + render json: @status, serializer: OEmbedSerializer, width: params[:maxwidth], height: params[:maxheight] end private @@ -23,12 +23,4 @@ class Api::OEmbedController < Api::BaseController def status_finder StatusFinder.new(params[:url]) end - - def maxwidth_or_default - (params[:maxwidth].presence || 400).to_i - end - - def maxheight_or_default - params[:maxheight].present? ? params[:maxheight].to_i : nil - end end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index a378425183..1b64eb4ef2 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -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 84b604b305..f7d3de7f94 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -16,6 +16,7 @@ class Api::V1::AccountsController < Api::BaseController before_action :check_account_confirmation, except: [:index, :create] before_action :check_enabled_registrations, only: [:create] before_action :check_accounts_limit, only: [:index] + before_action :check_following_self, only: [:follow] skip_before_action :require_authenticated_user!, only: :create @@ -101,8 +102,12 @@ class Api::V1::AccountsController < Api::BaseController raise(Mastodon::ValidationError) if account_ids.size > DEFAULT_ACCOUNTS_LIMIT end - def relationships(**options) - AccountRelationshipsPresenter.new([@account], current_user.account_id, **options) + def check_following_self + render json: { error: I18n.t('accounts.self_follow_error') }, status: 403 if current_user.account.id == @account.id + end + + def relationships(**) + AccountRelationshipsPresenter.new([@account], current_user.account_id, **) end def account_ids diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb index 9801d832b8..24f68aa1bd 100644 --- a/app/controllers/api/v1/admin/domain_allows_controller.rb +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController include AccountableConcern LIMIT = 100 + MAX_LIMIT = 500 before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show] @@ -47,18 +48,13 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController private def set_domain_allows - @domain_allows = filtered_domain_allows.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @domain_allows = DomainAllow.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_domain_allow @domain_allow = DomainAllow.find(params[:id]) end - def filtered_domain_allows - # TODO: no filtering yet - DomainAllow.all - end - def next_path api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -72,7 +68,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController end def records_continue? - @domain_allows.size == limit_param(LIMIT) + @domain_allows.size == limit_param(LIMIT, MAX_LIMIT) end def resource_params diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index a20a4a9c7f..b44ae2ae2a 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController include AccountableConcern LIMIT = 100 + MAX_LIMIT = 500 before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show] @@ -59,18 +60,13 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def set_domain_blocks - @domain_blocks = filtered_domain_blocks.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @domain_blocks = DomainBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_domain_block @domain_block = DomainBlock.find(params[:id]) end - def filtered_domain_blocks - # TODO: no filtering yet - DomainBlock.all - end - def domain_block_params params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) end @@ -88,7 +84,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController end def records_continue? - @domain_blocks.size == limit_param(LIMIT) + @domain_blocks.size == limit_param(LIMIT, MAX_LIMIT) end def resource_params 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/crypto/deliveries_controller.rb b/app/controllers/api/v1/crypto/deliveries_controller.rb deleted file mode 100644 index aa9df6e03b..0000000000 --- a/app/controllers/api/v1/crypto/deliveries_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::DeliveriesController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - def create - devices.each do |device_params| - DeliverToDeviceService.new.call(current_account, @current_device, device_params) - end - - render_empty - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end - - def resource_params - params.require(:device) - params.permit(device: [:account_id, :device_id, :type, :body, :hmac]) - end - - def devices - Array(resource_params[:device]) - end -end diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb deleted file mode 100644 index 93ae0e7771..0000000000 --- a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController - LIMIT = 80 - - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - before_action :set_encrypted_messages, only: :index - after_action :insert_pagination_headers, only: :index - - def index - render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer - end - - def clear - @current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all - render_empty - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end - - def set_encrypted_messages - @encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) - end - - def next_path - api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? - end - - def prev_path - api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? - end - - def pagination_collection - @encrypted_messages - end - - def records_continue? - @encrypted_messages.size == limit_param(LIMIT) - end -end diff --git a/app/controllers/api/v1/crypto/keys/claims_controller.rb b/app/controllers/api/v1/crypto/keys/claims_controller.rb deleted file mode 100644 index f9d202d67b..0000000000 --- a/app/controllers/api/v1/crypto/keys/claims_controller.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_claim_results - - def create - render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer - end - - private - - def set_claim_results - @claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) } - end - - def resource_params - params.permit(device: [:account_id, :device_id]) - end - - def devices - Array(resource_params[:device]) - end -end diff --git a/app/controllers/api/v1/crypto/keys/counts_controller.rb b/app/controllers/api/v1/crypto/keys/counts_controller.rb deleted file mode 100644 index ffd7151b78..0000000000 --- a/app/controllers/api/v1/crypto/keys/counts_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::CountsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_current_device - - def show - render json: { one_time_keys: @current_device.one_time_keys.count } - end - - private - - def set_current_device - @current_device = Device.find_by!(access_token: doorkeeper_token) - end -end diff --git a/app/controllers/api/v1/crypto/keys/queries_controller.rb b/app/controllers/api/v1/crypto/keys/queries_controller.rb deleted file mode 100644 index e6ce9f9192..0000000000 --- a/app/controllers/api/v1/crypto/keys/queries_controller.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::QueriesController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - before_action :set_accounts - before_action :set_query_results - - def create - render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer - end - - private - - def set_accounts - @accounts = Account.where(id: account_ids).includes(:devices) - end - - def set_query_results - @query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) } - end - - def account_ids - Array(params[:id]).map(&:to_i) - end -end diff --git a/app/controllers/api/v1/crypto/keys/uploads_controller.rb b/app/controllers/api/v1/crypto/keys/uploads_controller.rb deleted file mode 100644 index fc4abf63b3..0000000000 --- a/app/controllers/api/v1/crypto/keys/uploads_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Crypto::Keys::UploadsController < Api::BaseController - before_action -> { doorkeeper_authorize! :crypto } - before_action :require_user! - - def create - device = Device.find_or_initialize_by(access_token: doorkeeper_token) - - device.transaction do - device.account = current_account - device.update!(resource_params[:device]) - - if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable) - resource_params[:one_time_keys].each do |one_time_key_params| - device.one_time_keys.create!(one_time_key_params) - end - end - end - - render json: device, serializer: REST::Keys::DeviceSerializer - end - - private - - def resource_params - params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature]) - end -end diff --git a/app/controllers/api/v1/domain_blocks/previews_controller.rb b/app/controllers/api/v1/domain_blocks/previews_controller.rb new file mode 100644 index 0000000000..a917bddd98 --- /dev/null +++ b/app/controllers/api/v1/domain_blocks/previews_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Api::V1::DomainBlocks::PreviewsController < Api::BaseController + before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' } + before_action :require_user! + before_action :set_domain + before_action :set_domain_block_preview + + def show + render json: @domain_block_preview, serializer: REST::DomainBlockPreviewSerializer + end + + private + + def set_domain + @domain = TagManager.instance.normalize_domain(params[:domain]) + end + + def set_domain_block_preview + @domain_block_preview = with_read_replica do + DomainBlockPreviewPresenter.new( + following_count: current_account.following.where(domain: @domain).count, + followers_count: current_account.followers.where(domain: @domain).count + ) + end + end +end 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..e9e8e8ef55 --- /dev/null +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -0,0 +1,16 @@ +# 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::PrivacyPolicySerializer + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.live.first! + 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/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 1ec336f9a5..9d70c283be 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -8,12 +8,12 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController before_action :set_policy def show - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end def update @policy.update!(resource_params) - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end private diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb index 0e58379a38..3c90f13ce2 100644 --- a/app/controllers/api/v1/notifications/requests_controller.rb +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true class Api::V1::Notifications::RequestsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index - before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index + include Redisable + + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: [:index, :show, :merged?] + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: [:index, :show, :merged?] before_action :require_user! - before_action :set_request, except: :index + before_action :set_request, only: [:show, :accept, :dismiss] + before_action :set_requests, only: [:accept_bulk, :dismiss_bulk] after_action :insert_pagination_headers, only: :index @@ -18,6 +21,10 @@ class Api::V1::Notifications::RequestsController < Api::BaseController render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships end + def merged? + render json: { merged: redis.get("notification_unfilter_jobs:#{current_account.id}").to_i <= 0 } + end + def show render json: @request, serializer: REST::NotificationRequestSerializer end @@ -28,14 +35,24 @@ class Api::V1::Notifications::RequestsController < Api::BaseController end def dismiss - @request.update!(dismissed: true) + DismissNotificationRequestService.new.call(@request) + render_empty + end + + def accept_bulk + @requests.each { |request| AcceptNotificationRequestService.new.call(request) } + render_empty + end + + def dismiss_bulk + @requests.each(&:destroy!) render_empty end private def load_requests - requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed) || false).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( + requests = NotificationRequest.where(account: current_account).without_suspended.includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), params_slice(:max_id, :since_id, :min_id) ) @@ -53,14 +70,22 @@ class Api::V1::Notifications::RequestsController < Api::BaseController @request = NotificationRequest.where(account: current_account).find(params[:id]) end + def set_requests + @requests = NotificationRequest.where(account: current_account, id: Array(params[:id]).uniq.map(&:to_i)) + end + def next_path - api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty? + api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) if records_continue? end def prev_path api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty? end + def records_continue? + @requests.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + def pagination_max_id @requests.last.id end @@ -68,8 +93,4 @@ class Api::V1::Notifications::RequestsController < Api::BaseController def pagination_since_id @requests.first.id end - - def pagination_params(core_params) - params.slice(:dismissed).permit(:dismissed).merge(core_params) - end end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index c82900ef66..bdc163d4b6 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -7,6 +7,8 @@ class Api::V1::NotificationsController < Api::BaseController after_action :insert_pagination_headers, only: :index DEFAULT_NOTIFICATIONS_LIMIT = 40 + DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100 + MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000 def index with_read_replica do @@ -17,6 +19,14 @@ class Api::V1::NotificationsController < Api::BaseController render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships end + def unread_count + limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) + + with_read_replica do + render json: { count: browserable_account_notifications.paginate_by_min_id(limit, notification_marker&.last_read_id).count } + end + end + def show @notification = current_account.notifications.without_suspended.find(params[:id]) render json: @notification, serializer: REST::NotificationSerializer @@ -63,6 +73,10 @@ class Api::V1::NotificationsController < Api::BaseController ) end + def notification_marker + current_user.markers.find_by(timeline: 'notifications') + end + def target_statuses_from_notifications @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) end diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb index 1780554c5d..d9c8232702 100644 --- a/app/controllers/api/v1/peers/search_controller.rb +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -7,6 +7,8 @@ class Api::V1::Peers::SearchController < Api::BaseController skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? skip_around_action :set_locale + LIMIT = 10 + vary_by '' def index @@ -35,10 +37,10 @@ class Api::V1::Peers::SearchController < Api::BaseController field: 'accounts_count', modifier: 'log2p', }, - }).limit(10).pluck(:domain) + }).limit(LIMIT).pluck(:domain) else domain = normalized_domain - @domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain) + @domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain) end rescue Addressable::URI::InvalidURIError @domains = [] diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 513b937ef2..2833687a38 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -8,20 +8,20 @@ class Api::V1::Polls::VotesController < Api::BaseController before_action :set_poll def create - VoteService.new.call(current_account, @poll, vote_params[:choices]) + VoteService.new.call(current_account, @poll, vote_params) render json: @poll, serializer: REST::PollSerializer end 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 end def vote_params - params.permit(choices: []) + params.require(:choices) end end 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/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/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 300c9faa3f..72f358bb5b 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -10,7 +10,7 @@ class Api::V1::ReportsController < Api::BaseController @report = ReportService.new.call( current_account, reported_account, - report_params + report_params.merge(application: doorkeeper_token.application) ) render json: @report, serializer: REST::ReportSerializer diff --git a/app/controllers/api/v1/statuses/translations_controller.rb b/app/controllers/api/v1/statuses/translations_controller.rb index 8cf495f78a..bd5cd9bb07 100644 --- a/app/controllers/api/v1/statuses/translations_controller.rb +++ b/app/controllers/api/v1/statuses/translations_controller.rb @@ -23,6 +23,6 @@ class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseControl private def set_translation - @translation = TranslateStatusService.new.call(@status, content_locale) + @translation = TranslateStatusService.new.call(@status, I18n.locale.to_s) end end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index ee4cfab2ea..40b9707cf4 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/accounts_controller.rb b/app/controllers/api/v2/notifications/accounts_controller.rb new file mode 100644 index 0000000000..771e966388 --- /dev/null +++ b/app/controllers/api/v2/notifications/accounts_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' } + before_action :require_user! + before_action :set_notifications! + after_action :insert_pagination_headers, only: :index + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + @paginated_notifications.map(&:from_account) + end + + def set_notifications! + @paginated_notifications = begin + current_account + .notifications + .without_suspended + .where(group_key: params[:notification_group_key]) + .includes(from_account: [:account_stat, :user]) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + end + + def next_path + api_v2_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v2_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty? + end + + def pagination_collection + @paginated_notifications + end + + def records_continue? + @paginated_notifications.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end +end diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb new file mode 100644 index 0000000000..637587967f --- /dev/null +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :for_not_following, + :for_not_followers, + :for_new_accounts, + :for_private_mentions, + :for_limited_accounts + ) + end +end diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb new file mode 100644 index 0000000000..848c361cfc --- /dev/null +++ b/app/controllers/api/v2/notifications_controller.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +class Api::V2::NotificationsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss] + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss] + before_action :require_user! + after_action :insert_pagination_headers, only: :index + + DEFAULT_NOTIFICATIONS_LIMIT = 40 + DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100 + MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000 + + def index + with_read_replica do + @notifications = load_notifications + @grouped_notifications = load_grouped_notifications + @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) + @presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param) + + # Preload associations to avoid N+1s + ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call + end + + MastodonOTELTracer.in_span('Api::V2::NotificationsController#index rendering') do |span| + statuses = @grouped_notifications.filter_map { |group| group.target_status&.id } + + span.add_attributes( + 'app.notification_grouping.count' => @grouped_notifications.size, + 'app.notification_grouping.account.count' => @presenter.accounts.size, + 'app.notification_grouping.partial_account.count' => @presenter.partial_accounts.size, + 'app.notification_grouping.status.count' => statuses.size, + 'app.notification_grouping.status.unique_count' => statuses.uniq.size, + 'app.notification_grouping.expand_accounts_param' => expand_accounts_param + ) + + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param + end + end + + def unread_count + limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) + + with_read_replica do + render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id, grouped_types: params[:grouped_types]).count } + end + end + + def show + @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 + + def clear + current_account.notifications.delete_all + render_empty + end + + def dismiss + current_account.notifications.by_group_key(params[:group_key]).destroy_all + render_empty + end + + private + + def load_notifications + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_notifications') do + notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( + limit_param(DEFAULT_NOTIFICATIONS_LIMIT), + params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: []) + ) + + Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| + preload_collection(target_statuses, Status) + end + end + end + + def load_grouped_notifications + return [] if @notifications.empty? + + MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do + 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]), + exclude_types: Array(browserable_params[:exclude_types]), + include_filtered: truthy_param?(:include_filtered) + ) + end + + def notification_marker + current_user.markers.find_by(timeline: 'notifications') + end + + def target_statuses_from_notifications + @notifications.filter_map(&:target_status) + end + + def next_path + api_v2_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? + end + + def prev_path + api_v2_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? + end + + def pagination_collection + @notifications + end + + def browserable_params + params.slice(:include_filtered, :types, :exclude_types, :grouped_types).permit(:include_filtered, types: [], exclude_types: [], grouped_types: []) + end + + def pagination_params(core_params) + params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params) + end + + def expand_accounts_param + case params[:expand_accounts] + when nil, 'full' + 'full' + when 'partial_avatars' + 'partial_avatars' + else + raise Mastodon::InvalidParameterError, "Invalid value for 'expand_accounts': '#{params[:expand_accounts]}', allowed values are 'full' and 'partial_avatars'" + end + end +end diff --git a/app/controllers/api/v2_alpha/notifications_controller.rb b/app/controllers/api/v2_alpha/notifications_controller.rb deleted file mode 100644 index edba23ab4a..0000000000 --- a/app/controllers/api/v2_alpha/notifications_controller.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -class Api::V2Alpha::NotificationsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss] - before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss] - before_action :require_user! - after_action :insert_pagination_headers, only: :index - - DEFAULT_NOTIFICATIONS_LIMIT = 40 - - def index - with_read_replica do - @notifications = load_notifications - @group_metadata = load_group_metadata - @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) - end - - render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata - end - - def show - @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:id]) - render json: NotificationGroup.from_notification(@notification), serializer: REST::NotificationGroupSerializer - end - - def clear - current_account.notifications.delete_all - render_empty - end - - def dismiss - current_account.notifications.where(group_key: params[:id]).destroy_all - render_empty - end - - private - - def load_notifications - notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( - limit_param(DEFAULT_NOTIFICATIONS_LIMIT), - params_slice(:max_id, :since_id, :min_id) - ) - - Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| - preload_collection(target_statuses, Status) - end - end - - def load_group_metadata - return {} if @notifications.empty? - - browserable_account_notifications - .where(group_key: @notifications.filter_map(&:group_key)) - .where(id: (@notifications.last.id)..(@notifications.first.id)) - .group(:group_key) - .pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at') - .to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] } - end - - def browserable_account_notifications - current_account.notifications.without_suspended.browserable( - types: Array(browserable_params[:types]), - exclude_types: Array(browserable_params[:exclude_types]), - include_filtered: truthy_param?(:include_filtered) - ) - end - - def target_statuses_from_notifications - @notifications.filter_map(&:target_status) - end - - def next_path - api_v2_alpha_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? - end - - def prev_path - api_v2_alpha_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? - end - - def pagination_collection - @notifications - end - - def browserable_params - params.permit(:include_filtered, types: [], exclude_types: []) - end - - def pagination_params(core_params) - params.slice(:limit, :types, :exclude_types, :include_filtered).permit(:limit, :include_filtered, types: [], exclude_types: []).merge(core_params) - end -end diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index 63c3f2d90a..f82c1c50d7 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -9,7 +9,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController return not_found if @status.hidden? if @status.local? - render json: @status, serializer: OEmbedSerializer, width: 400 + render json: @status, serializer: OEmbedSerializer else return not_found unless user_signed_in? diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 167d16fc4d..2711071b4a 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::Web::PushSubscriptionsController < Api::Web::BaseController - before_action :require_user! + before_action :require_user!, except: :destroy before_action :set_push_subscription, only: :update before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions? after_action :update_session_with_subscription, only: :create @@ -17,6 +17,13 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end + def destroy + push_subscription = ::Web::PushSubscription.find_by_token_for(:unsubscribe, params[:id]) + push_subscription&.destroy + + head 200 + end + private def active_session @@ -59,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 @@ -69,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 1d700fa282..f9c06cb904 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -23,10 +23,8 @@ class ApplicationController < ActionController::Base helper_method :current_theme helper_method :single_user_mode? helper_method :use_seamless_external_login? - helper_method :omniauth_only? 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 @@ -36,7 +34,7 @@ class ApplicationController < ActionController::Base rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests - rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable rescue_from Seahorse::Client::NetworkingError do |e| @@ -75,7 +73,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? @@ -140,10 +144,6 @@ class ApplicationController < ActionController::Base Devise.pam_authentication || Devise.ldap_authentication end - def omniauth_only? - ENV['OMNIAUTH_ONLY'] == 'true' - end - def sso_account_settings ENV.fetch('SSO_ACCOUNT_SETTINGS', nil) end @@ -160,10 +160,6 @@ class ApplicationController < ActionController::Base @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? 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/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 7ca7be5f8e..bf5c84ccf4 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -5,7 +5,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' - before_action :set_body_classes before_action :set_confirmation_user!, only: [:show, :confirm_captcha] before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? @@ -73,10 +72,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? end - def set_body_classes - @body_classes = 'lighter' - end - def after_resending_confirmation_instructions_path_for(_resource_name) if user_signed_in? if current_user.confirmed? && current_user.approved? diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index de001f062b..7c1ff59671 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -3,7 +3,6 @@ class Auth::PasswordsController < Devise::PasswordsController skip_before_action :check_self_destruct! before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? - before_action :set_body_classes layout 'auth' @@ -24,10 +23,6 @@ class Auth::PasswordsController < Devise::PasswordsController redirect_to new_password_path(resource_name) end - def set_body_classes - @body_classes = 'lighter' - end - def reset_password_token_is_valid? resource_class.with_reset_password_token(params[:reset_password_token]).present? end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index e5a2ac0270..6e34b6b627 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -11,9 +11,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] before_action :set_strikes, only: [:edit, :update] - before_action :set_body_classes, only: [:new, :create, :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 @@ -104,10 +102,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController private - def set_body_classes - @body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter' - end - def set_invite @invite = begin invite = Invite.find_by(code: invite_code) if invite_code.present? @@ -144,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 6ed7b2baac..250573fc7d 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -16,17 +16,10 @@ class Auth::SessionsController < Devise::SessionsController include Auth::TwoFactorAuthenticationConcern - before_action :set_body_classes - content_security_policy only: :new do |p| p.form_action(false) end - def check_suspicious! - user = find_user - @login_is_suspicious = suspicious_sign_in?(user) unless user.nil? - end - def create super do |resource| # We only need to call this if this hasn't already been @@ -80,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) @@ -103,8 +96,9 @@ class Auth::SessionsController < Devise::SessionsController private - def set_body_classes - @body_classes = 'lighter' + def check_suspicious! + user = find_user + @login_is_suspicious = suspicious_sign_in?(user) unless user.nil? end def home_paths(resource) @@ -193,4 +187,15 @@ class Auth::SessionsController < Devise::SessionsController def second_factor_attempts_key(user) "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" end + + def respond_to_on_destroy + respond_to do |format| + format.json do + render json: { + redirect_to: after_sign_out_path_for(resource_name), + }, status: 200 + end + format.all { super } + end + end end diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb index 40916d2887..5e7b14646a 100644 --- a/app/controllers/auth/setup_controller.rb +++ b/app/controllers/auth/setup_controller.rb @@ -5,7 +5,6 @@ class Auth::SetupController < ApplicationController before_action :authenticate_user! before_action :require_unconfirmed_or_pending! - before_action :set_body_classes before_action :set_user skip_before_action :require_functional! @@ -19,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,11 +34,7 @@ class Auth::SetupController < ApplicationController @user = current_user end - def set_body_classes - @body_classes = 'lighter' - end - def user_params - params.require(:user).permit(:email) + params.expect(user: [:email]) end end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index d63bcc85c9..b75f3e3581 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -20,7 +20,7 @@ module AccountControllerConcern webfinger_account_link, actor_url_link, ] - ) + ).to_s end def webfinger_account_link 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/api/error_handling.rb b/app/controllers/concerns/api/error_handling.rb index ad559fe2d7..9ce4795b02 100644 --- a/app/controllers/concerns/api/error_handling.rb +++ b/app/controllers/concerns/api/error_handling.rb @@ -20,7 +20,7 @@ module Api::ErrorHandling render json: { error: 'Record not found' }, status: 404 end - rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, Mastodon::UnexpectedResponseError) do render json: { error: 'Remote data could not be fetched' }, status: 503 end diff --git a/app/controllers/concerns/api/pagination.rb b/app/controllers/concerns/api/pagination.rb index 7f06dc0202..b0b4ae4603 100644 --- a/app/controllers/concerns/api/pagination.rb +++ b/app/controllers/concerns/api/pagination.rb @@ -19,7 +19,7 @@ module Api::Pagination links = [] links << [next_path, [%w(rel next)]] if next_path links << [prev_path, [%w(rel prev)]] if prev_path - response.headers['Link'] = LinkHeader.new(links) unless links.empty? + response.headers['Link'] = LinkHeader.new(links).to_s unless links.empty? end def require_valid_pagination_options! diff --git a/app/controllers/concerns/auth/captcha_concern.rb b/app/controllers/concerns/auth/captcha_concern.rb index cfd93978ce..c01da21249 100644 --- a/app/controllers/concerns/auth/captcha_concern.rb +++ b/app/controllers/concerns/auth/captcha_concern.rb @@ -10,7 +10,7 @@ module Auth::CaptchaConcern end def captcha_available? - ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present? end def captcha_enabled? diff --git a/app/controllers/concerns/auth/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb index 404164751a..0fb11428dc 100644 --- a/app/controllers/concerns/auth/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/auth/two_factor_authentication_concern.rb @@ -83,7 +83,6 @@ module Auth::TwoFactorAuthenticationConcern def prompt_for_two_factor(user) register_attempt_in_session(user) - @body_classes = 'lighter' @webauthn_enabled = user.webauthn_enabled? @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? 'webauthn' 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 09874fb405..7fbc469bdf 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -42,7 +42,6 @@ module ChallengableConcern end def render_challenge - @body_classes = 'lighter' render 'auth/challenges/new', layout: 'auth' end @@ -59,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 68f09ee023..5f7ef8dd63 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -80,7 +80,7 @@ module SignatureVerification fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature'] rescue SignatureVerificationError => e fail_with! e.message - rescue HTTP::Error, OpenSSL::SSL::SSLError => e + rescue *Mastodon::HTTP_CONNECTION_ERRORS => e fail_with! "Failed to fetch remote data: #{e.message}" rescue Mastodon::UnexpectedResponseError fail_with! 'Failed to fetch remote data (got unexpected reply from server)' @@ -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 24cccf1667..5e395786f0 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -7,21 +7,27 @@ 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 + + if policy.sso_host.present? + p.form_action policy.sso_host, -> { "https://#{request.host}/auth/auth/" } + else + p.form_action :none + end + end end def skip_csrf_meta_tags? !(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? # NOTE: Different from upstream because we allow moved users to log in - permalink_redirector = PermalinkRedirector.new(request.path) + permalink_redirector = PermalinkRedirector.new(request.original_fullpath) return if permalink_redirector.redirect_path.blank? expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? @@ -36,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 1054f3db80..07677fd3f3 100644 --- a/app/controllers/disputes/base_controller.rb +++ b/app/controllers/disputes/base_controller.rb @@ -7,17 +7,5 @@ class Disputes::BaseController < ApplicationController skip_before_action :require_functional! - before_action :set_body_classes before_action :authenticate_user! - before_action :set_cache_headers - - private - - def set_body_classes - @body_classes = 'admin' - end - - 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 94993f938b..d85b017aaa 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -6,8 +6,6 @@ class Filters::StatusesController < ApplicationController before_action :authenticate_user! before_action :set_filter before_action :set_status_filters - before_action :set_body_classes - before_action :set_cache_headers PER_PAGE = 20 @@ -35,18 +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_body_classes - @body_classes = 'admin' - 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 bd9964426b..769aea2afe 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -5,8 +5,6 @@ class FiltersController < ApplicationController before_action :authenticate_user! before_action :set_filter, only: [:edit, :update, :destroy] - before_action :set_body_classes - before_action :set_cache_headers def index @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase) @@ -49,14 +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_body_classes - @body_classes = 'admin' - 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 15f38c74ee..ab8d77f353 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 9bc5164d59..fc65333ac4 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,8 +6,6 @@ class InvitesController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_body_classes - before_action :set_cache_headers def index authorize :invite, :create? @@ -44,14 +42,6 @@ class InvitesController < ApplicationController end def resource_params - params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment) - end - - def set_body_classes - @body_classes = 'admin' - 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/mail_subscriptions_controller.rb b/app/controllers/mail_subscriptions_controller.rb index 1caeaaacf4..34df75f63a 100644 --- a/app/controllers/mail_subscriptions_controller.rb +++ b/app/controllers/mail_subscriptions_controller.rb @@ -5,7 +5,6 @@ class MailSubscriptionsController < ApplicationController skip_before_action :require_functional! - before_action :set_body_classes before_action :set_user before_action :set_type @@ -25,10 +24,6 @@ class MailSubscriptionsController < ApplicationController not_found unless @user end - def set_body_classes - @body_classes = 'lighter' - end - def set_type @type = email_type_from_param end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 53eee40012..9d10468e69 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -19,9 +19,7 @@ class MediaController < ApplicationController redirect_to @media_attachment.file.url(:original) end - def player - @body_classes = 'player' - end + def player; end private diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index c4230d62c3..f68d85e44e 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -13,7 +13,7 @@ class MediaProxyController < ApplicationController rescue_from ActiveRecord::RecordInvalid, with: :not_found rescue_from Mastodon::UnexpectedResponseError, with: :not_found rescue_from Mastodon::NotPermittedError, with: :not_found - rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) def show with_redis_lock("media_download:#{params[:id]}") do diff --git a/app/controllers/oauth/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 7bb22453ca..8b11a519ea 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -6,8 +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_body_classes - before_action :set_cache_headers before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json } @@ -23,10 +21,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio private - def set_body_classes - @body_classes = 'admin' - end - def store_current_location store_location_for(:user, request.url) end @@ -35,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/redirect/base_controller.rb b/app/controllers/redirect/base_controller.rb index 90894ec1ed..34558a4126 100644 --- a/app/controllers/redirect/base_controller.rb +++ b/app/controllers/redirect/base_controller.rb @@ -4,7 +4,6 @@ class Redirect::BaseController < ApplicationController vary_by 'Accept-Language' before_action :set_resource - before_action :set_app_body_class def show @redirect_path = ActivityPub::TagManager.instance.url_for(@resource) @@ -14,10 +13,6 @@ class Redirect::BaseController < ApplicationController private - def set_app_body_class - @body_classes = 'app-body' - end - def set_resource raise NotImplementedError end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index dd794f3199..7e793fc734 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -6,8 +6,6 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show before_action :set_relationships, only: :show - before_action :set_body_classes - before_action :set_cache_headers helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -37,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? @@ -67,12 +65,4 @@ class RelationshipsController < ApplicationController 'remove_domains_from_followers' end end - - def set_body_classes - @body_classes = 'admin' - 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 f15140aa2b..7f2279aa8f 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -4,19 +4,9 @@ class Settings::BaseController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_body_classes - before_action :set_cache_headers private - def set_body_classes - @body_classes = 'admin' - end - - 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/exports_controller.rb b/app/controllers/settings/exports_controller.rb index 076ed5dadb..263d20eaea 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -9,7 +9,7 @@ class Settings::ExportsController < Settings::BaseController skip_before_action :require_functional! def show - @export = Export.new(current_account) + @export_summary = ExportSummary.new(preloaded_account) @backups = current_user.backups end @@ -25,4 +25,15 @@ class Settings::ExportsController < Settings::BaseController redirect_to settings_export_path end + + private + + def preloaded_account + current_account.tap do |account| + ActiveRecord::Associations::Preloader.new( + records: [account], + associations: :account_stat + ).call + end + end end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index 90c112e219..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/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..a5bb3b884f 100644 --- a/app/controllers/settings/privacy_controller.rb +++ b/app/controllers/settings/privacy_controller.rb @@ -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..458f4148cc 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -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/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb index 0bff01ec27..ca8d46afe4 100644 --- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -15,7 +15,7 @@ module Settings end def create - session[:new_otp_secret] = User.generate_otp_secret(32) + session[:new_otp_secret] = User.generate_otp_secret redirect_to new_settings_two_factor_authentication_confirmation_path end diff --git a/app/controllers/settings/verifications_controller.rb b/app/controllers/settings/verifications_controller.rb index fc4f23bb18..bed29dbeec 100644 --- a/app/controllers/settings/verifications_controller.rb +++ b/app/controllers/settings/verifications_controller.rb @@ -2,14 +2,32 @@ class Settings::VerificationsController < Settings::BaseController before_action :set_account + before_action :set_verified_links - def show - @verified_links = @account.fields.select(&:verified?) + def show; end + + def update + if UpdateAccountService.new.call(@account, account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end end private + def account_params + params.expect(account: [:attribution_domains]).tap do |params| + params[:attribution_domains] = params[:attribution_domains].split if params[:attribution_domains] + end + end + def set_account @account = current_account end + + def set_verified_links + @verified_links = @account.fields.select(&:verified?) + end end diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb index 168e85e3fe..817abebf62 100644 --- a/app/controllers/severed_relationships_controller.rb +++ b/app/controllers/severed_relationships_controller.rb @@ -4,8 +4,6 @@ class SeveredRelationshipsController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_body_classes - before_action :set_cache_headers before_action :set_event, only: [:following, :followers] @@ -50,12 +48,4 @@ class SeveredRelationshipsController < ApplicationController def acct(account) account.local? ? account.local_username_and_domain : account.acct end - - def set_body_classes - @body_classes = 'admin' - end - - def set_cache_headers - response.cache_control.replace(private: true, no_store: true) - end end diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index 6546b84978..1aa0ce5a0d 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -4,13 +4,6 @@ class SharesController < ApplicationController layout 'modal' before_action :authenticate_user! - before_action :set_body_classes def show; end - - private - - def set_body_classes - @body_classes = 'modal-layout compose-standalone' - end end diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb index 4a3fc10ca4..f4f49031a0 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -5,8 +5,6 @@ class StatusesCleanupController < ApplicationController before_action :authenticate_user! before_action :set_policy - before_action :set_body_classes - before_action :set_cache_headers def show; end @@ -16,8 +14,6 @@ class StatusesCleanupController < ApplicationController else render :show end - rescue ActionController::ParameterMissing - # Do nothing end def require_functional! @@ -31,14 +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_body_classes - @body_classes = 'admin' - 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/statuses_controller.rb b/app/controllers/statuses_controller.rb index db7eddd78b..341b0e6472 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -11,7 +11,6 @@ class StatusesController < ApplicationController before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :redirect_to_original, only: :show - before_action :set_body_classes, only: :embed after_action :set_link_headers @@ -51,12 +50,10 @@ class StatusesController < ApplicationController private - def set_body_classes - @body_classes = 'with-modals' - end - def set_link_headers - response.headers['Link'] = LinkHeader.new([[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]) + response.headers['Link'] = LinkHeader.new( + [[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]] + ).to_s end def set_status 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/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 201da9fbc3..6dee587baf 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -7,7 +7,23 @@ module WellKnown def show @webfinger_template = "#{webfinger_url}?resource={uri}" expires_in 3.days, public: true - render content_type: 'application/xrd+xml', formats: [:xml] + + respond_to do |format| + format.any do + render content_type: 'application/xrd+xml', formats: [:xml] + end + + format.json do + render json: { + links: [ + { + rel: 'lrdd', + template: @webfinger_template, + }, + ], + } + end + end end end end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 110a53e4e1..8e32a92923 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -19,14 +19,6 @@ module AccountsHelper end end - def account_action_button(account) - return if account.memorial? || account.moved? - - link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do - safe_join([logo_as_symbol, t('accounts.follow')]) - end - end - def hide_followers_count?(account) Setting.hide_followers_count || account.user&.settings&.[]('hide_followers_count') end diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb index 2a3d954a35..7c931c1157 100644 --- a/app/helpers/admin/account_moderation_notes_helper.rb +++ b/app/helpers/admin/account_moderation_notes_helper.rb @@ -12,12 +12,12 @@ module Admin::AccountModerationNotesHelper ) end - def admin_account_inline_link_to(account) + def admin_account_inline_link_to(account, path: nil) return if account.nil? link_to( account_inline_text(account), - admin_account_path(account.id), + path || admin_account_path(account.id), class: class_names('inline-name-tag', suspended: suspended_account?(account)), title: account.acct ) diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index e8d5634126..859f924687 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -33,6 +33,15 @@ module Admin::ActionLogsHelper else I18n.t('admin.action_logs.deleted_account') end + when 'Relay' + link_to log.human_identifier, admin_relays_path end end + + def sorted_action_log_types + Admin::ActionLogFilter::ACTION_TYPE_MAP + .keys + .map { |key| [I18n.t("admin.action_logs.action_types.#{key}"), key] } + .sort_by(&:first) + end end diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb index 6096ff1381..f87fdad708 100644 --- a/app/helpers/admin/dashboard_helper.rb +++ b/app/helpers/admin/dashboard_helper.rb @@ -18,6 +18,11 @@ module Admin::DashboardHelper end end + def date_range(range) + [l(range.first), l(range.last)] + .join(' - ') + end + def relevant_account_timestamp(account) timestamp, exact = if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago [account.user_current_sign_in_at, true] @@ -25,6 +30,8 @@ module Admin::DashboardHelper [account.user_current_sign_in_at, false] elsif account.user_pending? [account.user_created_at, true] + elsif account.suspended_at.present? && account.local? && account.user.nil? + [account.suspended_at, true] elsif account.last_status_at.present? [account.last_status_at, true] else diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 140fc73ede..40806a4515 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -25,7 +25,7 @@ module Admin::FilterHelper end def table_link_to(icon, text, path, **options) - link_to safe_join([fa_icon(icon), text]), path, options.merge(class: 'table-action-link') + link_to safe_join([material_symbol(icon), text]), path, options.merge(class: 'table-action-link') end def selected?(more_params) diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb index 6937331e1a..9b950d5a63 100644 --- a/app/helpers/admin/settings_helper.rb +++ b/app/helpers/admin/settings_helper.rb @@ -2,7 +2,7 @@ module Admin::SettingsHelper def captcha_available? - ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present? end def login_activity_title(activity) diff --git a/app/helpers/admin/tags_helper.rb b/app/helpers/admin/tags_helper.rb new file mode 100644 index 0000000000..eb928a6db2 --- /dev/null +++ b/app/helpers/admin/tags_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Admin::TagsHelper + def admin_tags_moderation_options + [ + [t('admin.tags.moderation.reviewed'), 'reviewed'], + [t('admin.tags.moderation.review_requested'), 'review_requested'], + [t('admin.tags.moderation.unreviewed'), 'unreviewed'], + [t('admin.tags.moderation.trendable'), 'trendable'], + [t('admin.tags.moderation.not_trendable'), 'not_trendable'], + [t('admin.tags.moderation.usable'), 'usable'], + [t('admin.tags.moderation.not_usable'), 'not_usable'], + ] + end +end diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb index 79fee44dc4..c7a59660cf 100644 --- a/app/helpers/admin/trends/statuses_helper.rb +++ b/app/helpers/admin/trends/statuses_helper.rb @@ -5,7 +5,7 @@ module Admin::Trends::StatusesHelper text = if status.local? status.text.split("\n").first else - Nokogiri::HTML(status.text).css('html > body > *').first&.text + Nokogiri::HTML5(status.text).css('html > body > *').first&.text end return '' if text.blank? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2369cff7e6..32beed43ab 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true module ApplicationHelper - DANGEROUS_SCOPES = %w( - read - write - follow - ).freeze - RTL_LOCALES = %i( ar ckb @@ -85,8 +79,8 @@ module ApplicationHelper def html_title safe_join( - [content_for(:page_title).to_s.chomp, title] - .select(&:present?), + [content_for(:page_title), title] + .compact_blank, ' - ' ) end @@ -95,8 +89,11 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end - def class_for_scope(scope) - 'scope-danger' if DANGEROUS_SCOPES.include?(scope.to_s) + def label_for_scope(scope) + safe_join [ + tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), + tag.span(t("doorkeeper.scopes.#{scope}"), class: :hint), + ] end def can?(action, record) @@ -105,19 +102,17 @@ module ApplicationHelper policy(record).public_send(:"#{action}?") end - def fa_icon(icon, attributes = {}) - class_names = attributes[:class]&.split || [] - class_names << 'fa' - class_names += icon.split.map { |cl| "fa-#{cl}" } - - content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) - end - def material_symbol(icon, attributes = {}) - inline_svg_tag( - "400-24px/#{icon}.svg", - class: %w(icon).concat(attributes[:class].to_s.split), - role: :img + safe_join( + [ + inline_svg_tag( + "400-24px/#{icon}.svg", + class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split), + role: :img, + data: attributes[:data] + ), + ' ', + ] ) end @@ -125,25 +120,13 @@ module ApplicationHelper inline_svg_tag 'check.svg' end - def visibility_icon(status) - if status.public_visibility? - fa_icon('globe', title: I18n.t('statuses.visibilities.public')) - elsif status.unlisted_visibility? - fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) - elsif status.private_visibility? || status.limited_visibility? - fa_icon('lock', title: I18n.t('statuses.visibilities.private')) - elsif status.direct_visibility? - fa_icon('at', title: I18n.t('statuses.visibilities.direct')) - end - end - def interrelationships_icon(relationships, account_id) if relationships.following[account_id] && relationships.followed_by[account_id] - fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive') + material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive') elsif relationships.following[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active') + material_symbol(locale_direction == 'ltr' ? 'arrow_right_alt' : 'arrow_left_alt', title: I18n.t('relationships.following'), class: 'active') elsif relationships.followed_by[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive') + material_symbol(locale_direction == 'ltr' ? 'arrow_left_alt' : 'arrow_right_alt', title: I18n.t('relationships.followers'), class: 'passive') end end @@ -160,10 +143,12 @@ module ApplicationHelper end def body_classes - output = body_class_string.split + output = [] + output << content_for(:body_classes) output << "flavour-#{current_flavour.parameterize}" output << "skin-#{current_skin.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(' ') @@ -245,20 +230,21 @@ module ApplicationHelper full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg')) end - def instance_presenter - @instance_presenter ||= InstancePresenter.new + def copyable_input(options = {}) + tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options) end - def favicon_path(size = '48') - instance_presenter.favicon&.file&.url(size) + def recent_tag_usage(tag) + people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts + I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people end - def app_icon_path(size = '48') - instance_presenter.app_icon&.file&.url(size) + def app_store_url_ios + 'https://apps.apple.com/app/mastodon-for-iphone-and-ipad/id1571998974' end - def use_mask_icon? - instance_presenter.app_icon.blank? + def app_store_url_android + 'https://play.google.com/store/apps/details?id=org.joinmastodon.android' end # glitch-soc addition to handle the multiple flavors @@ -267,12 +253,12 @@ module ApplicationHelper preload_pack_asset "locales/#{current_flavour}/#{I18n.locale}-json.js" if supported_locales.include?(I18n.locale.to_s) end - def flavoured_javascript_pack_tag(pack_name, **options) - javascript_pack_tag("flavours/#{current_flavour}/#{pack_name}", **options) + def flavoured_javascript_pack_tag(pack_name, **) + javascript_pack_tag("flavours/#{current_flavour}/#{pack_name}", **) end - def flavoured_stylesheet_pack_tag(pack_name, **options) - stylesheet_pack_tag("flavours/#{current_flavour}/#{pack_name}", **options) + def flavoured_stylesheet_pack_tag(pack_name, **) + stylesheet_pack_tag("flavours/#{current_flavour}/#{pack_name}", **) end def preload_signed_in_js_packs diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index d70b2a88fd..b36e25c092 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -24,24 +24,8 @@ module ContextHelper indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' }, memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, - olm: { - 'toot' => 'http://joinmastodon.org/ns#', - 'Device' => 'toot:Device', - 'Ed25519Signature' => 'toot:Ed25519Signature', - 'Ed25519Key' => 'toot:Ed25519Key', - 'Curve25519Key' => 'toot:Curve25519Key', - 'EncryptedMessage' => 'toot:EncryptedMessage', - 'publicKeyBase64' => 'toot:publicKeyBase64', - 'deviceId' => 'toot:deviceId', - 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, - 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, - 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, - 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, - 'messageFranking' => 'toot:messageFranking', - 'messageType' => 'toot:messageType', - 'cipherText' => 'toot:cipherText', - }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, + attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, }.freeze def full_context diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index f0d583bc54..9c4bed92ee 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true module FormattingHelper + SYNDICATED_EMOJI_STYLES = <<~CSS.squish + height: 1.1em; + margin: -.2ex .15em .2ex; + object-fit: contain; + vertical-align: middle; + width: 1.1em; + CSS + def html_aware_format(text, local, options = {}) HtmlAwareFormatter.new(text, local, options).to_s end @@ -19,42 +27,33 @@ module FormattingHelper module_function :extract_status_plain_text def status_content_format(status) - html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type) + 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) : []), content_type: status.content_type) + end end def rss_status_content_format(status) - html = status_content_format(status) - - before_html = if status.spoiler_text? - tag.p do - tag.strong do - I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale) - end - - status.spoiler_text - end + tag.hr - end - - after_html = if status.preloadable_poll - tag.p do - safe_join( - status.preloadable_poll.options.map do |o| - tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', o, disabled: true) - end, - tag.br - ) - end - end - prerender_custom_emojis( - safe_join([before_html, html, after_html]), + wrapped_status_content_format(status), status.emojis, - style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' + style: SYNDICATED_EMOJI_STYLES ).to_str end def account_bio_format(account) - html_aware_format(account.note, account.local?) + MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span| + span.add_attributes( + 'app.formatter.content.type' => 'account_bio', + 'app.formatter.content.origin' => account.local? ? 'local' : 'remote' + ) + + html_aware_format(account.note, account.local?) + end end def account_field_value_format(field, with_rel_me: true) @@ -64,4 +63,51 @@ module FormattingHelper html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false) 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) + safe_join [ + rss_content_preroll(status), + status_content_format(status), + rss_content_postroll(status), + ] + end + + def rss_content_preroll(status) + if status.spoiler_text? + safe_join [ + tag.p { spoiler_with_warning(status) }, + tag.hr, + ] + end + end + + def spoiler_with_warning(status) + safe_join [ + tag.strong { I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale) }, + status.spoiler_text, + ] + end + + def rss_content_postroll(status) + if status.preloadable_poll + tag.p do + poll_option_tags(status) + end + end + end + + def poll_option_tags(status) + safe_join( + status.preloadable_poll.options.map do |option| + tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', option, disabled: true) + end, + tag.br + ) + end end diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb index 893afdd51f..018c69e620 100644 --- a/app/helpers/instance_helper.rb +++ b/app/helpers/instance_helper.rb @@ -13,6 +13,22 @@ module InstanceHelper safe_join([description_prefix(invite), I18n.t('auth.description.suffix')], ' ') end + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end + + def favicon_path(size = '48') + instance_presenter.favicon&.file&.url(size) + end + + def app_icon_path(size = '48') + instance_presenter.app_icon&.file&.url(size) + end + + def use_mask_icon? + instance_presenter.app_icon.blank? + end + private def description_prefix(invite) diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb new file mode 100644 index 0000000000..ba096427cf --- /dev/null +++ b/app/helpers/json_ld_helper.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +module JsonLdHelper + include ContextHelper + + def equals_or_includes?(haystack, needle) + haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle + end + + def equals_or_includes_any?(haystack, needles) + needles.any? { |needle| equals_or_includes?(haystack, needle) } + end + + def first_of_value(value) + value.is_a?(Array) ? value.first : value + end + + def uri_from_bearcap(str) + if str&.start_with?('bear:') + Addressable::URI.parse(str).query_values['u'] + else + str + end + end + + # The url attribute can be a string, an array of strings, or an array of objects. + # The objects could include a mimeType. Not-included mimeType means it's text/html. + def url_to_href(value, preferred_type = nil) + single_value = if value.is_a?(Array) && !value.first.is_a?(String) + value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) } + elsif value.is_a?(Array) + value.first + else + value + end + + if single_value.nil? || single_value.is_a?(String) + single_value + else + single_value['href'] + end + end + + def as_array(value) + if value.nil? + [] + elsif value.is_a?(Array) + value + else + [value] + end + end + + def value_or_id(value) + value.is_a?(String) || value.nil? ? value : value['id'] + end + + def supported_context?(json) + !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) + end + + def unsupported_uri_scheme?(uri) + uri.nil? || !uri.start_with?('http://', 'https://') + end + + def non_matching_uri_hosts?(base_url, comparison_url) + return true if unsupported_uri_scheme?(comparison_url) + + needle = Addressable::URI.parse(comparison_url).host + haystack = Addressable::URI.parse(base_url).host + + !haystack.casecmp(needle).zero? + end + + def canonicalize(json) + graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context)) + graph.dump(:normalize) + end + + def compact(json) + compacted = JSON::LD::API.compact(json.without('signature'), full_context, documentLoader: method(:load_jsonld_context)) + compacted['signature'] = json['signature'] + compacted + end + + # Patches a JSON-LD document to avoid compatibility issues on redistribution + # + # Since compacting a JSON-LD document against Mastodon's built-in vocabulary + # means other extension namespaces will be expanded, malformed JSON-LD + # attributes lost, and some values “unexpectedly” compacted this method + # patches the following likely sources of incompatibility: + # - 'https://www.w3.org/ns/activitystreams#Public' being compacted to + # 'as:Public' (for instance, pre-3.4.0 Mastodon does not understand + # 'as:Public') + # - single-item arrays being compacted to the item itself (`[foo]` being + # compacted to `foo`) + # + # It is not always possible for `patch_for_forwarding!` to produce a document + # deemed safe for forwarding. Use `safe_for_forwarding?` to check the status + # of the output document. + # + # @param original [Hash] The original JSON-LD document used as reference + # @param compacted [Hash] The compacted JSON-LD document to be patched + # @return [void] + def patch_for_forwarding!(original, compacted) + original.without('@context', 'signature').each do |key, value| + next if value.nil? || !compacted.key?(key) + + compacted_value = compacted[key] + if value.is_a?(Hash) && compacted_value.is_a?(Hash) + patch_for_forwarding!(value, compacted_value) + elsif value.is_a?(Array) + compacted_value = [compacted_value] unless compacted_value.is_a?(Array) + return if value.size != compacted_value.size + + compacted[key] = value.zip(compacted_value).map do |v, vc| + if v.is_a?(Hash) && vc.is_a?(Hash) + patch_for_forwarding!(v, vc) + vc + elsif v == 'https://www.w3.org/ns/activitystreams#Public' && vc == 'as:Public' + v + else + vc + end + end + elsif value == 'https://www.w3.org/ns/activitystreams#Public' && compacted_value == 'as:Public' + compacted[key] = value + end + end + end + + # Tests whether a JSON-LD compaction is deemed safe for redistribution, + # that is, if it doesn't change its meaning to consumers that do not actually + # handle JSON-LD, but rely on values being serialized in a certain way. + # + # See `patch_for_forwarding!` for details. + # + # @param original [Hash] The original JSON-LD document used as reference + # @param compacted [Hash] The compacted JSON-LD document to be patched + # @return [Boolean] Whether the patched document is deemed safe + def safe_for_forwarding?(original, compacted) + original.without('@context', 'signature').all? do |key, value| + compacted_value = compacted[key] + return false unless value.instance_of?(compacted_value.class) + + if value.is_a?(Hash) + safe_for_forwarding?(value, compacted_value) + elsif value.is_a?(Array) + value.zip(compacted_value).all? do |v, vc| + v.is_a?(Hash) ? (vc.is_a?(Hash) && safe_for_forwarding?(v, vc)) : v == vc + end + else + value == compacted_value + end + end + end + + def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {}) + unless id_is_known + json = fetch_resource_without_id_validation(uri, on_behalf_of) + + 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.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: {}) + 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 + + body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response) + end + end + + def valid_activitypub_content_type?(response) + return true if response.mime_type == 'application/activity+json' + + # When the mime type is `application/ld+json`, we need to check the profile, + # but `http.rb` does not parse it for us. + return false unless response.mime_type == 'application/ld+json' + + response.headers[HTTP::Headers::CONTENT_TYPE]&.split(';')&.map(&:strip)&.any? do |str| + str.start_with?('profile="') && str[9...-1].split.include?('https://www.w3.org/ns/activitystreams') + end + end + + def body_to_json(body, compare_id: nil) + json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body + + return if compare_id.present? && json['id'] != compare_id + + json + rescue Oj::ParseError + nil + end + + def response_successful?(response) + (200...300).cover?(response.code) + end + + def response_error_unsalvageable?(response) + response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) + end + + def build_request(uri, on_behalf_of = nil, options: {}) + Request.new(:get, uri, **options).tap do |request| + request.on_behalf_of(on_behalf_of) if on_behalf_of + request.add_headers('Accept' => 'application/activity+json, application/ld+json') + end + end + + def load_jsonld_context(url, _options = {}, &block) + json = Rails.cache.fetch("jsonld:context:#{url}", expires_in: 30.days, raw: true) do + request = Request.new(:get, url) + request.add_headers('Accept' => 'application/ld+json') + request.perform do |res| + raise JSON::LD::JsonLdError::LoadingDocumentFailed unless res.code == 200 && res.mime_type == 'application/ld+json' + + res.body_with_limit + end + end + + doc = JSON::LD::API::RemoteDocument.new(json, documentUrl: url) + + block ? yield(doc) : doc + end +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb deleted file mode 100644 index 932a3420db..0000000000 --- a/app/helpers/jsonld_helper.rb +++ /dev/null @@ -1,241 +0,0 @@ -# frozen_string_literal: true - -module JsonLdHelper - include ContextHelper - - def equals_or_includes?(haystack, needle) - haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle - end - - def equals_or_includes_any?(haystack, needles) - needles.any? { |needle| equals_or_includes?(haystack, needle) } - end - - def first_of_value(value) - value.is_a?(Array) ? value.first : value - end - - def uri_from_bearcap(str) - if str&.start_with?('bear:') - Addressable::URI.parse(str).query_values['u'] - else - str - end - end - - # The url attribute can be a string, an array of strings, or an array of objects. - # The objects could include a mimeType. Not-included mimeType means it's text/html. - def url_to_href(value, preferred_type = nil) - single_value = if value.is_a?(Array) && !value.first.is_a?(String) - value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) } - elsif value.is_a?(Array) - value.first - else - value - end - - if single_value.nil? || single_value.is_a?(String) - single_value - else - single_value['href'] - end - end - - def as_array(value) - if value.nil? - [] - elsif value.is_a?(Array) - value - else - [value] - end - end - - def value_or_id(value) - value.is_a?(String) || value.nil? ? value : value['id'] - end - - def supported_context?(json) - !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) - end - - def unsupported_uri_scheme?(uri) - uri.nil? || !uri.start_with?('http://', 'https://') - end - - def non_matching_uri_hosts?(base_url, comparison_url) - return true if unsupported_uri_scheme?(comparison_url) - - needle = Addressable::URI.parse(comparison_url).host - haystack = Addressable::URI.parse(base_url).host - - !haystack.casecmp(needle).zero? - end - - def canonicalize(json) - graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context)) - graph.dump(:normalize) - end - - def compact(json) - compacted = JSON::LD::API.compact(json.without('signature'), full_context, documentLoader: method(:load_jsonld_context)) - compacted['signature'] = json['signature'] - compacted - end - - # Patches a JSON-LD document to avoid compatibility issues on redistribution - # - # Since compacting a JSON-LD document against Mastodon's built-in vocabulary - # means other extension namespaces will be expanded, malformed JSON-LD - # attributes lost, and some values “unexpectedly” compacted this method - # patches the following likely sources of incompatibility: - # - 'https://www.w3.org/ns/activitystreams#Public' being compacted to - # 'as:Public' (for instance, pre-3.4.0 Mastodon does not understand - # 'as:Public') - # - single-item arrays being compacted to the item itself (`[foo]` being - # compacted to `foo`) - # - # It is not always possible for `patch_for_forwarding!` to produce a document - # deemed safe for forwarding. Use `safe_for_forwarding?` to check the status - # of the output document. - # - # @param original [Hash] The original JSON-LD document used as reference - # @param compacted [Hash] The compacted JSON-LD document to be patched - # @return [void] - def patch_for_forwarding!(original, compacted) - original.without('@context', 'signature').each do |key, value| - next if value.nil? || !compacted.key?(key) - - compacted_value = compacted[key] - if value.is_a?(Hash) && compacted_value.is_a?(Hash) - patch_for_forwarding!(value, compacted_value) - elsif value.is_a?(Array) - compacted_value = [compacted_value] unless compacted_value.is_a?(Array) - return if value.size != compacted_value.size - - compacted[key] = value.zip(compacted_value).map do |v, vc| - if v.is_a?(Hash) && vc.is_a?(Hash) - patch_for_forwarding!(v, vc) - vc - elsif v == 'https://www.w3.org/ns/activitystreams#Public' && vc == 'as:Public' - v - else - vc - end - end - elsif value == 'https://www.w3.org/ns/activitystreams#Public' && compacted_value == 'as:Public' - compacted[key] = value - end - end - end - - # Tests whether a JSON-LD compaction is deemed safe for redistribution, - # that is, if it doesn't change its meaning to consumers that do not actually - # handle JSON-LD, but rely on values being serialized in a certain way. - # - # See `patch_for_forwarding!` for details. - # - # @param original [Hash] The original JSON-LD document used as reference - # @param compacted [Hash] The compacted JSON-LD document to be patched - # @return [Boolean] Whether the patched document is deemed safe - def safe_for_forwarding?(original, compacted) - original.without('@context', 'signature').all? do |key, value| - compacted_value = compacted[key] - return false unless value.instance_of?(compacted_value.class) - - if value.is_a?(Hash) - safe_for_forwarding?(value, compacted_value) - elsif value.is_a?(Array) - value.zip(compacted_value).all? do |v, vc| - v.is_a?(Hash) ? (vc.is_a?(Hash) && safe_for_forwarding?(v, vc)) : v == vc - end - else - value == compacted_value - end - end - end - - def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {}) - unless id_is_known - json = fetch_resource_without_id_validation(uri, on_behalf_of) - - 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.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: {}) - 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 - - body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response) - end - end - - def valid_activitypub_content_type?(response) - return true if response.mime_type == 'application/activity+json' - - # When the mime type is `application/ld+json`, we need to check the profile, - # but `http.rb` does not parse it for us. - return false unless response.mime_type == 'application/ld+json' - - response.headers[HTTP::Headers::CONTENT_TYPE]&.split(';')&.map(&:strip)&.any? do |str| - str.start_with?('profile="') && str[9...-1].split.include?('https://www.w3.org/ns/activitystreams') - end - end - - def body_to_json(body, compare_id: nil) - json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body - - return if compare_id.present? && json['id'] != compare_id - - json - rescue Oj::ParseError - nil - end - - def merge_context(context, new_context) - if context.is_a?(Array) - context << new_context - else - [context, new_context] - end - end - - def response_successful?(response) - (200...300).cover?(response.code) - end - - def response_error_unsalvageable?(response) - response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) - end - - def build_request(uri, on_behalf_of = nil, options: {}) - Request.new(:get, uri, **options).tap do |request| - request.on_behalf_of(on_behalf_of) if on_behalf_of - request.add_headers('Accept' => 'application/activity+json, application/ld+json') - end - end - - def load_jsonld_context(url, _options = {}, &block) - json = Rails.cache.fetch("jsonld:context:#{url}", expires_in: 30.days, raw: true) do - request = Request.new(:get, url) - request.add_headers('Accept' => 'application/ld+json') - request.perform do |res| - raise JSON::LD::JsonLdError::LoadingDocumentFailed unless res.code == 200 && res.mime_type == 'application/ld+json' - - res.body_with_limit - end - end - - doc = JSON::LD::API::RemoteDocument.new(json, documentUrl: url) - - block ? yield(doc) : doc - end -end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 9e1c0a7db1..0a8ebcde54 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -162,7 +162,7 @@ module LanguagesHelper th: ['Thai', 'ไทย'].freeze, ti: ['Tigrinya', 'ትግርኛ'].freeze, tk: ['Turkmen', 'Türkmen'].freeze, - tl: ['Tagalog', 'Wikang Tagalog'].freeze, + tl: ['Tagalog', 'Tagalog'].freeze, tn: ['Tswana', 'Setswana'].freeze, to: ['Tonga', 'faka Tonga'].freeze, tr: ['Turkish', 'Türkçe'].freeze, @@ -193,6 +193,7 @@ module LanguagesHelper ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze, csb: ['Kashubian', 'Kaszëbsczi'].freeze, + gsw: ['Swiss German', 'Schwiizertütsch'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze, ldn: ['Láadan', 'Láadan'].freeze, @@ -238,9 +239,7 @@ module LanguagesHelper # Helper for self.sorted_locale_keys private_class_method def self.locale_name_for_sorting(locale) - if locale.blank? || locale == 'und' - '000' - elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) + if (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) ASCIIFolding.new.fold(supported_locale[1]).downcase elsif (regional_locale = REGIONAL_LOCALE_NAMES[locale.to_sym]) ASCIIFolding.new.fold(regional_locale).downcase diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb index fa8f34fb4d..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,45 +38,25 @@ 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 } end end - def render_card_component(status, **options) - component_params = { - sensitive: sensitive_viewer?(status, current_account), - card: serialize_status_card(status).as_json, - }.merge(**options) - - react_component :card, component_params - end - - def render_poll_component(status, **options) - component_params = { - disabled: true, - poll: serialize_status_poll(status).as_json, - }.merge(**options) - - react_component :poll, component_params do - render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? } - end - end - private def serialize_media_attachment(attachment) @@ -86,22 +66,6 @@ module MediaComponentHelper ) end - def serialize_status_card(status) - ActiveModelSerializers::SerializableResource.new( - status.preview_card, - serializer: REST::PreviewCardSerializer - ) - end - - def serialize_status_poll(status) - ActiveModelSerializers::SerializableResource.new( - status.preloadable_poll, - serializer: REST::PollSerializer, - scope: current_user, - scope_name: :current_user - ) - end - def sensitive_viewer?(status, account) if !account.nil? && account.id == status.account_id status.sensitive diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index ef5462ac88..002d167c05 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -16,6 +16,6 @@ module RegistrationHelper end def ip_blocked?(remote_ip) - IpBlock.where(severity: :sign_up_block).exists?(['ip >>= ?', remote_ip.to_s]) + IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? end end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 15d988f64d..22efc5f092 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -14,8 +14,8 @@ module RoutingHelper end end - def full_asset_url(source, **options) - source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage? + def full_asset_url(source, **) + source = ActionController::Base.helpers.asset_url(source, **) unless use_storage? URI.join(asset_host, source).to_s end @@ -24,12 +24,12 @@ module RoutingHelper Rails.configuration.action_controller.asset_host || root_url end - def frontend_asset_path(source, **options) - asset_pack_path("media/#{source}", **options) + def frontend_asset_path(source, **) + asset_pack_path("media/#{source}", **) end - def frontend_asset_url(source, **options) - full_asset_url(frontend_asset_path(source, **options)) + def frontend_asset_url(source, **) + full_asset_url(frontend_asset_path(source, **)) end def use_storage? diff --git a/app/helpers/self_destruct_helper.rb b/app/helpers/self_destruct_helper.rb index 78557c25e5..f1927b1e04 100644 --- a/app/helpers/self_destruct_helper.rb +++ b/app/helpers/self_destruct_helper.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true module SelfDestructHelper + VERIFY_PURPOSE = 'self-destruct' + def self.self_destruct? - value = ENV.fetch('SELF_DESTRUCT', nil) - value.present? && Rails.application.message_verifier('self-destruct').verify(value) == ENV['LOCAL_DOMAIN'] + value = Rails.configuration.x.mastodon.self_destruct_value + value.present? && Rails.application.message_verifier(VERIFY_PURPOSE).verify(value) == ENV['LOCAL_DOMAIN'] rescue ActiveSupport::MessageVerifier::InvalidSignature false end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 10863a316c..fd631ce92e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -10,27 +10,28 @@ module SettingsHelper end def featured_tags_hint(recently_used_tags) - safe_join( - [ - t('simple_form.hints.featured_tag.name'), - safe_join( - links_for_featured_tags(recently_used_tags), - ', ' - ), - ], - ' ' - ) + recently_used_tags.present? && + safe_join( + [ + t('simple_form.hints.featured_tag.name'), + safe_join( + links_for_featured_tags(recently_used_tags), + ', ' + ), + ], + ' ' + ) end def session_device_icon(session) device = session.detection.device if device.mobile? - 'mobile' + 'smartphone' elsif device.tablet? 'tablet' else - 'desktop' + 'desktop_mac' end end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index ca693a8a78..16b9d3fb53 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true module StatusesHelper - EMBEDDED_CONTROLLER = 'statuses' - EMBEDDED_ACTION = 'embed' + VISIBLITY_ICONS = { + public: 'globe', + unlisted: 'lock_open', + private: 'lock', + direct: 'alternate_email', + }.freeze def nothing_here(extra_classes = '') - content_tag(:div, class: "nothing-here #{extra_classes}") do + tag.div(class: ['nothing-here', extra_classes]) do t('accounts.nothing_here') end end @@ -53,25 +57,8 @@ module StatusesHelper components.compact_blank.join("\n\n") end - def stream_link_target - embedded_view? ? '_blank' : nil - end - - def fa_visibility_icon(status) - case status.visibility - when 'public' - fa_icon 'globe fw' - when 'unlisted' - fa_icon 'unlock fw' - when 'private' - fa_icon 'lock fw' - when 'direct' - fa_icon 'at fw' - end - end - - def embedded_view? - params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION + def visibility_icon(status) + VISIBLITY_ICONS[status.visibility.to_sym] end def prefers_autoplay? diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index bb8ef4547c..0a4ddbc7c4 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -5,8 +5,10 @@ module ThemeHelper flavour, theme = flavour_and_skin if theme == 'system' - stylesheet_pack_tag("skins/#{flavour}/mastodon-light", media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + - stylesheet_pack_tag("skins/#{flavour}/default", media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + ''.html_safe.tap do |tags| + tags << stylesheet_pack_tag("skins/#{flavour}/mastodon-light", media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + tags << stylesheet_pack_tag("skins/#{flavour}/default", media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + end else stylesheet_pack_tag "skins/#{flavour}/#{theme}", media: 'all', crossorigin: 'anonymous' end @@ -16,15 +18,42 @@ module ThemeHelper _, theme = flavour_and_skin if theme == 'system' - tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') + - tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') + ''.html_safe.tap do |tags| + tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') + tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') + end else tag.meta name: 'theme-color', content: theme_color_for(theme) 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/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb deleted file mode 100644 index 482f4e19ea..0000000000 --- a/app/helpers/webfinger_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module WebfingerHelper - def webfinger!(uri) - Webfinger.new(uri).perform - end -end diff --git a/app/javascript/entrypoints/common.js b/app/javascript/entrypoints/common.js index 489041458f..fce857be92 100644 --- a/app/javascript/entrypoints/common.js +++ b/app/javascript/entrypoints/common.js @@ -2,4 +2,3 @@ that is implicitly generated as the common chunk through webpack's `splitChunks` config */ import './public-path'; -import 'font-awesome/css/font-awesome.css'; diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx new file mode 100644 index 0000000000..cb62727563 --- /dev/null +++ b/app/javascript/entrypoints/embed.tsx @@ -0,0 +1,78 @@ +import './public-path'; +import { createRoot } from 'react-dom/client'; + +import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal'; + +import { start } from '../mastodon/common'; +import { Status } from '../mastodon/features/standalone/status'; +import { loadPolyfills } from '../mastodon/polyfills'; +import ready from '../mastodon/ready'; + +start(); + +function loaded() { + const mountNode = document.getElementById('mastodon-status'); + + if (mountNode) { + const attr = mountNode.getAttribute('data-props'); + + if (!attr) return; + + const props = JSON.parse(attr) as { id: string; locale: string }; + const root = createRoot(mountNode); + + root.render(); + } +} + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); + +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + 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( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0]?.scrollHeight, + }, + '*', + ); + }); +}); diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 40a9b7c0ca..0560e76628 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -37,43 +37,6 @@ const messages = defineMessages({ }, }); -interface SetHeightMessage { - type: 'setHeight'; - id: string; - height: number; -} - -function isSetHeightMessage(data: unknown): data is SetHeightMessage { - if ( - data && - typeof data === 'object' && - 'type' in data && - data.type === 'setHeight' - ) - return true; - else return false; -} - -window.addEventListener('message', (e) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases - if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; - - const data = e.data; - - ready(() => { - window.parent.postMessage( - { - type: 'setHeight', - id: data.id, - height: document.getElementsByTagName('html')[0]?.scrollHeight, - }, - '*', - ); - }).catch((e: unknown) => { - console.error('Error in setHeightMessage postMessage', e); - }); -}); - function loaded() { const { messages: localeData } = getLocale(); @@ -156,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; }); @@ -267,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( @@ -364,31 +275,24 @@ Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { if (!input) return; - const oldReadOnly = input.readOnly; - - input.readOnly = false; - input.focus(); - input.select(); - input.setSelectionRange(0, input.value.length); - - try { - if (document.execCommand('copy')) { - input.blur(); - + navigator.clipboard + .writeText(input.value) + .then(() => { const parent = target.parentElement; - if (!parent) return; - parent.classList.add('copied'); + if (parent) { + parent.classList.add('copied'); - setTimeout(() => { - parent.classList.remove('copied'); - }, 700); - } - } catch (err) { - console.error(err); - } + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } - input.readOnly = oldReadOnly; + return true; + }) + .catch((error: unknown) => { + console.error(error); + }); }); const toggleSidebar = () => { @@ -431,6 +335,42 @@ Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => { target.src = target.dataset.static; }); +const setInputDisabled = ( + input: HTMLInputElement | HTMLSelectElement, + disabled: boolean, +) => { + input.disabled = disabled; + + const wrapper = input.closest('.with_label'); + if (wrapper) { + wrapper.classList.toggle('disabled', input.disabled); + + const hidden = + input.type === 'checkbox' && + wrapper.querySelector('input[type=hidden][value="0"]'); + if (hidden) { + hidden.disabled = input.disabled; + } + } +}; + +Rails.delegate( + document, + '#account_statuses_cleanup_policy_enabled', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement) || !target.form) return; + + target.form + .querySelectorAll< + HTMLInputElement | HTMLSelectElement + >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select') + .forEach((input) => { + setInputDisabled(input, !target.checked); + }); + }, +); + // Empty the honeypot fields in JS in case something like an extension // automatically filled them. Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { @@ -447,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/flavours/glitch/actions/account_notes.ts b/app/javascript/flavours/glitch/actions/account_notes.ts index a71b342b06..162fa67505 100644 --- a/app/javascript/flavours/glitch/actions/account_notes.ts +++ b/app/javascript/flavours/glitch/actions/account_notes.ts @@ -6,5 +6,4 @@ export const submitAccountNote = createDataLoadingThunk( ({ accountId, note }: { accountId: string; note: string }) => apiSubmitAccountNote(accountId, note), (relationship) => ({ relationship }), - { skipLoading: true }, ); diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index 7c31c16998..513b9e24a9 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -1,3 +1,6 @@ +import { browserHistory } from 'flavours/glitch/components/router'; +import { debounceWithDispatchAndArguments } from 'flavours/glitch/utils/debounce'; + import api, { getLinks } from '../api'; import { @@ -71,19 +74,6 @@ export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; -export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; -export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; -export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; - -export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST'; -export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS'; -export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL'; - -export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR'; -export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE'; - -export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET'; - export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL'; export * from './accounts_typed'; @@ -460,6 +450,20 @@ export function expandFollowingFail(id, error) { }; } +const debouncedFetchRelationships = debounceWithDispatchAndArguments((dispatch, ...newAccountIds) => { + if (newAccountIds.length === 0) { + return; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchRelationshipsSuccess({ relationships: response.data })); + }).catch(error => { + dispatch(fetchRelationshipsFail(error)); + }); +}, { delay: 500 }); + export function fetchRelationships(accountIds) { return (dispatch, getState) => { const state = getState(); @@ -471,13 +475,7 @@ export function fetchRelationships(accountIds) { return; } - dispatch(fetchRelationshipsRequest(newAccountIds)); - - api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { - dispatch(fetchRelationshipsSuccess({ relationships: response.data })); - }).catch(error => { - dispatch(fetchRelationshipsFail(error)); - }); + debouncedFetchRelationships(dispatch, ...newAccountIds); }; } @@ -675,38 +673,6 @@ export function unpinAccountFail(error) { }; } -export function fetchPinnedAccounts() { - return (dispatch) => { - dispatch(fetchPinnedAccountsRequest()); - - api().get('/api/v1/endorsements', { params: { limit: 0 } }).then(response => { - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchPinnedAccountsSuccess(response.data)); - }).catch(err => dispatch(fetchPinnedAccountsFail(err))); - }; -} - -export function fetchPinnedAccountsRequest() { - return { - type: PINNED_ACCOUNTS_FETCH_REQUEST, - }; -} - -export function fetchPinnedAccountsSuccess(accounts, next) { - return { - type: PINNED_ACCOUNTS_FETCH_SUCCESS, - accounts, - next, - }; -} - -export function fetchPinnedAccountsFail(error) { - return { - type: PINNED_ACCOUNTS_FETCH_FAIL, - error, - }; -} - export const updateAccount = ({ displayName, note, avatar, header, discoverable, indexable }) => (dispatch) => { const data = new FormData(); @@ -722,61 +688,12 @@ export const updateAccount = ({ displayName, note, avatar, header, discoverable, }); }; -export function fetchPinnedAccountsSuggestions(q) { - return (dispatch) => { - dispatch(fetchPinnedAccountsSuggestionsRequest()); +export const navigateToProfile = (accountId) => { + return (_dispatch, getState) => { + const acct = getState().accounts.getIn([accountId, 'acct']); - const params = { - q, - resolve: false, - limit: 4, - following: true, - }; - - api().get('/api/v1/accounts/search', { params }).then(response => { - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchPinnedAccountsSuggestionsSuccess(q, response.data)); - }).catch(err => dispatch(fetchPinnedAccountsSuggestionsFail(err))); + if (acct) { + browserHistory.push(`/@${acct}`); + } }; -} - -export function fetchPinnedAccountsSuggestionsRequest() { - return { - type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST, - }; -} - -export function fetchPinnedAccountsSuggestionsSuccess(query, accounts) { - return { - type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS, - query, - accounts, - }; -} - -export function fetchPinnedAccountsSuggestionsFail(error) { - return { - type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL, - error, - }; -} - -export function clearPinnedAccountsSuggestions() { - return { - type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR, - }; -} - -export function changePinnedAccountsSuggestions(value) { - return { - type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE, - value, - }; -} - -export function resetPinnedAccountsEditor() { - return { - type: PINNED_ACCOUNTS_EDITOR_RESET, - }; -} - +}; diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js deleted file mode 100644 index 42834146bf..0000000000 --- a/app/javascript/flavours/glitch/actions/alerts.js +++ /dev/null @@ -1,59 +0,0 @@ -import { defineMessages } from 'react-intl'; - -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, - }); - } - - console.error(error); - - return showAlert({ - title: messages.unexpectedTitle, - message: messages.unexpectedMessage, - }); -}; diff --git a/app/javascript/flavours/glitch/actions/alerts.ts b/app/javascript/flavours/glitch/actions/alerts.ts new file mode 100644 index 0000000000..a521f3ef35 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/app.ts b/app/javascript/flavours/glitch/actions/app.ts index 6fbfc07f68..be1a5cced2 100644 --- a/app/javascript/flavours/glitch/actions/app.ts +++ b/app/javascript/flavours/glitch/actions/app.ts @@ -2,6 +2,9 @@ import { createAction } from '@reduxjs/toolkit'; import type { LayoutType } from '../is_mobile'; +export const focusApp = createAction('APP_FOCUS'); +export const unfocusApp = createAction('APP_UNFOCUS'); + interface ChangeLayoutPayload { layout: LayoutType; } diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 61245acdba..e038dac88b 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -4,6 +4,7 @@ import axios from 'axios'; import { throttle } from 'lodash'; import api from 'flavours/glitch/api'; +import { browserHistory } from 'flavours/glitch/components/router'; import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'flavours/glitch/settings'; import { recoverHashtags } from 'flavours/glitch/utils/hashtag'; @@ -94,9 +95,9 @@ const messages = defineMessages({ saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, }); -export const ensureComposeIsVisible = (getState, routerHistory) => { +export const ensureComposeIsVisible = (getState) => { if (!getState().getIn(['compose', 'mounted'])) { - routerHistory.push('/publish'); + browserHistory.push('/publish'); } }; @@ -117,7 +118,7 @@ export function changeCompose(text) { }; } -export function replyCompose(status, routerHistory) { +export function replyCompose(status) { return (dispatch, getState) => { const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']); dispatch({ @@ -126,7 +127,19 @@ export function replyCompose(status, routerHistory) { prependCWRe: prependCWRe, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); + }; +} + +export function replyComposeById(statusId) { + return (dispatch, getState) => { + const state = getState(); + const status = state.statuses.get(statusId); + + if (status) { + const account = state.accounts.get(status.get('account')); + dispatch(replyCompose(status.set('account', account))); + } }; } @@ -142,38 +155,47 @@ export function resetCompose() { }; } -export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => { +export const focusCompose = (defaultText) => (dispatch, getState) => { dispatch({ type: COMPOSE_FOCUS, defaultText, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; -export function mentionCompose(account, routerHistory) { +export function mentionCompose(account) { return (dispatch, getState) => { dispatch({ type: COMPOSE_MENTION, account: account, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; } -export function directCompose(account, routerHistory) { +export function mentionComposeById(accountId) { + return (dispatch, getState) => { + dispatch(mentionCompose(getState().accounts.get(accountId))); + }; +} + +export function directCompose(account) { return (dispatch, getState) => { dispatch({ type: COMPOSE_DIRECT, account: account, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; } -export function submitCompose(routerHistory, overridePrivacy = null) { +/** + * @param {null | string} overridePrivacy + */ +export function submitCompose(overridePrivacy = null) { return function (dispatch, getState) { let status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); @@ -230,11 +252,10 @@ export function submitCompose(routerHistory, overridePrivacy = null) { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { - if (routerHistory - && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new') + if ((browserHistory.location.pathname === '/publish' || browserHistory.location.pathname === '/statuses/new') && window.history.state && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) { - routerHistory.goBack(); + browserHistory.goBack(); } dispatch(insertIntoTagHistory(response.data.tags, status)); @@ -272,7 +293,7 @@ export function submitCompose(routerHistory, overridePrivacy = null) { message: statusId === null ? messages.published : messages.saved, action: messages.open, dismissAfter: 10000, - onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`), })); } }).catch(function (error) { @@ -310,7 +331,7 @@ export function doodleSet(options) { export function uploadCompose(files) { return function (dispatch, getState) { - const uploadLimit = 4; + const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']); const media = getState().getIn(['compose', 'media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); @@ -330,7 +351,7 @@ export function uploadCompose(files) { dispatch(uploadComposeRequest()); for (const [i, f] of Array.from(files).entries()) { - if (media.size + i > 3) break; + if (media.size + i > (uploadLimit - 1)) break; resizeImage(f).then(file => { const data = new FormData(); @@ -430,7 +451,7 @@ export function initMediaEditModal(id) { dispatch(openModal({ modalType: 'FOCAL_POINT', - modalProps: { id }, + modalProps: { mediaId: id }, })); }; } diff --git a/app/javascript/flavours/glitch/actions/compose_typed.ts b/app/javascript/flavours/glitch/actions/compose_typed.ts new file mode 100644 index 0000000000..70be8abcd2 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/compose_typed.ts @@ -0,0 +1,70 @@ +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; + +import { apiUpdateMedia } from 'flavours/glitch/api/compose'; +import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments'; +import type { MediaAttachment } from 'flavours/glitch/models/media_attachment'; +import { createDataLoadingThunk } from 'flavours/glitch/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/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index 7341ba8550..535104df74 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -1,10 +1,12 @@ +import { createPollFromServerJSON } from 'flavours/glitch/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/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 5f10c8d889..962ddc60f3 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -1,15 +1,12 @@ import escapeTextContentForBrowser from 'escape-html'; +import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji'; + import emojify from '../../features/emoji/emoji'; import { autoHideCW } from '../../utils/content_warning'; 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'); @@ -104,38 +101,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/flavours/glitch/actions/importer/polls.ts b/app/javascript/flavours/glitch/actions/importer/polls.ts new file mode 100644 index 0000000000..318d721985 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/polls.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Poll } from 'flavours/glitch/models/poll'; + +export const importPolls = createAction<{ polls: Poll[] }>( + 'poll/importMultiple', +); diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 57f2459c01..92142d782c 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -1,7 +1,11 @@ +import { boostModal, favouriteModal } from 'flavours/glitch/initial_state'; + import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { unreblog, reblog } from './interactions_typed'; +import { openModal } from './modal'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -432,3 +436,61 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +function toggleReblogWithoutConfirmation(status, visibility) { + return (dispatch) => { + if (status.get('reblogged')) { + dispatch(unreblog({ statusId: status.get('id') })); + } else { + dispatch(reblog({ statusId: status.get('id'), visibility })); + } + }; +} + +export function toggleReblog(statusId, skipModal = false) { + return (dispatch, getState) => { + const state = getState(); + let status = state.statuses.get(statusId); + + if (!status) + return; + + // The reblog modal expects a pre-filled account in status + // TODO: fix this by having the reblog modal get a statusId and do the work itself + status = status.set('account', state.accounts.get(status.get('account'))); + + const missing_description_setting = state.getIn(['local_settings', 'confirm_boost_missing_media_description']); + const missing_description = status.get('media_attachments').some(item => !item.get('description')); + if (missing_description_setting && missing_description && !status.get('reblogged')) { + dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: (status, privacy) => dispatch(toggleReblogWithoutConfirmation(status, privacy)), missingMediaDescription: true } })); + } else if (boostModal && !skipModal) { + dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: (status, privacy) => dispatch(toggleReblogWithoutConfirmation(status, privacy)) } })); + } else { + dispatch(toggleReblogWithoutConfirmation(status)); + } + }; +} + +export function toggleFavourite(statusId, skipModal = false) { + return (dispatch, getState) => { + const state = getState(); + let status = state.statuses.get(statusId); + + if (!status) + return; + + // The favourite modal expects a pre-filled account in status + // TODO: fix this by having the reblog modal get a statusId and do the work itself + status = status.set('account', state.accounts.get(status.get('account'))); + + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + if (favouriteModal && !skipModal) { + dispatch(openModal({ modalType: 'FAVOURITE', modalProps: { status, onFavourite: (status) => dispatch(favourite(status)) } })); + } else { + dispatch(favourite(status)); + } + } + }; +} diff --git a/app/javascript/flavours/glitch/actions/languages.js b/app/javascript/flavours/glitch/actions/languages.js deleted file mode 100644 index ad186ba0cc..0000000000 --- a/app/javascript/flavours/glitch/actions/languages.js +++ /dev/null @@ -1,12 +0,0 @@ -import { saveSettings } from './settings'; - -export const LANGUAGE_USE = 'LANGUAGE_USE'; - -export const useLanguage = language => dispatch => { - dispatch({ - type: LANGUAGE_USE, - language, - }); - - dispatch(saveSettings()); -}; diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js index 9956059387..f9abc2e769 100644 --- a/app/javascript/flavours/glitch/actions/lists.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/lists_typed.ts b/app/javascript/flavours/glitch/actions/lists_typed.ts new file mode 100644 index 0000000000..8f5dc0ffa3 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/lists_typed.ts @@ -0,0 +1,13 @@ +import { apiCreate, apiUpdate } from 'flavours/glitch/api/lists'; +import type { List } from 'flavours/glitch/models/list'; +import { createDataLoadingThunk } from 'flavours/glitch/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/flavours/glitch/actions/markers.ts b/app/javascript/flavours/glitch/actions/markers.ts index a85af1c4be..ea325109c5 100644 --- a/app/javascript/flavours/glitch/actions/markers.ts +++ b/app/javascript/flavours/glitch/actions/markers.ts @@ -37,8 +37,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk( }); return; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if ('navigator' && 'sendBeacon' in navigator) { + } else if ('sendBeacon' in navigator) { // Failing that, we can use sendBeacon, but we have to encode the data as // FormData for DoorKeeper to recognize the token. const formData = new FormData(); @@ -64,7 +63,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk( client.setRequestHeader('Content-Type', 'application/json'); client.setRequestHeader('Authorization', `Bearer ${accessToken}`); client.send(JSON.stringify(params)); - } catch (e) { + } catch { // Do not make the BeforeUnload handler error out } }, @@ -75,9 +74,7 @@ interface MarkerParam { } function getLastNotificationId(state: RootState): string | undefined { - // @ts-expect-error state.notifications is not yet typed - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call - return state.getIn(['notifications', 'lastReadId']); + return state.notificationGroups.lastReadId; } const buildPostMarkersParams = (state: RootState) => { diff --git a/app/javascript/flavours/glitch/actions/modal.ts b/app/javascript/flavours/glitch/actions/modal.ts index 01a95051e3..9e653c5f4c 100644 --- a/app/javascript/flavours/glitch/actions/modal.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/notification_groups.ts b/app/javascript/flavours/glitch/actions/notification_groups.ts new file mode 100644 index 0000000000..a1f872c3e3 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notification_groups.ts @@ -0,0 +1,253 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { + apiClearNotifications, + apiFetchNotificationGroups, +} from 'flavours/glitch/api/notifications'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, + NotificationType, +} from 'flavours/glitch/api_types/notifications'; +import { allNotificationTypes } from 'flavours/glitch/api_types/notifications'; +import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; +import { usePendingItems } from 'flavours/glitch/initial_state'; +import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups'; +import { + selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsGroupFollows, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsShows, +} from 'flavours/glitch/selectors/settings'; +import type { AppDispatch, RootState } from 'flavours/glitch/store'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'flavours/glitch/store/typed_functions'; + +import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { NOTIFICATIONS_FILTER_SET } from './notifications'; +import { saveSettings } from './settings'; + +function excludeAllTypesExcept(filter: string) { + return allNotificationTypes.filter((item) => item !== filter); +} + +function getExcludedTypes(state: RootState) { + const activeFilter = selectSettingsNotificationsQuickFilterActive(state); + + return activeFilter === 'all' + ? selectSettingsNotificationsExcludedTypes(state) + : excludeAllTypesExcept(activeFilter); +} + +function dispatchAssociatedRecords( + dispatch: AppDispatch, + notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], +) { + const fetchedAccounts: ApiAccountJSON[] = []; + const fetchedStatuses: ApiStatusJSON[] = []; + + notifications.forEach((notification) => { + if (notification.type === 'admin.report') { + fetchedAccounts.push(notification.report.target_account); + } + + if (notification.type === 'moderation_warning') { + fetchedAccounts.push(notification.moderation_warning.target_account); + } + + if ('status' in notification && notification.status) { + fetchedStatuses.push(notification.status); + } + }); + + if (fetchedAccounts.length > 0) + dispatch(importFetchedAccounts(fetchedAccounts)); + + if (fetchedStatuses.length > 0) + dispatch(importFetchedStatuses(fetchedStatuses)); +} + +function selectNotificationGroupedTypes(state: RootState) { + const types: NotificationType[] = ['favourite', 'reblog']; + + if (selectSettingsNotificationsGroupFollows(state)) types.push('follow'); + + return types; +} + +export const fetchNotifications = createDataLoadingThunk( + 'notificationGroups/fetch', + async (_params, { getState }) => + apiFetchNotificationGroups({ + grouped_types: selectNotificationGroupedTypes(getState()), + exclude_types: getExcludedTypes(getState()), + }), + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + const payload: (ApiNotificationGroupJSON | NotificationGap)[] = + notifications; + + // TODO: might be worth not using gaps for that… + // if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri }); + if (notifications.length > 1) + payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id }); + + return payload; + // dispatch(submitMarkers()); + }, +); + +export const fetchNotificationsGap = createDataLoadingThunk( + 'notificationGroups/fetchGap', + async (params: { gap: NotificationGap }, { getState }) => + apiFetchNotificationGroups({ + grouped_types: selectNotificationGroupedTypes(getState()), + max_id: params.gap.maxId, + exclude_types: getExcludedTypes(getState()), + }), + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + +export const pollRecentNotifications = createDataLoadingThunk( + 'notificationGroups/pollRecentNotifications', + async (_params, { getState }) => { + return apiFetchNotificationGroups({ + grouped_types: selectNotificationGroupedTypes(getState()), + max_id: undefined, + exclude_types: getExcludedTypes(getState()), + // In slow mode, we don't want to include notifications that duplicate the already-displayed ones + since_id: usePendingItems + ? getState().notificationGroups.groups.find( + (group) => group.type !== 'gap', + )?.page_max_id + : undefined, + }); + }, + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, + { + useLoadingBar: false, + }, +); + +export const processNewNotificationForGroups = createAppAsyncThunk( + 'notificationGroups/processNew', + (notification: ApiNotificationJSON, { dispatch, getState }) => { + const state = getState(); + const activeFilter = selectSettingsNotificationsQuickFilterActive(state); + const notificationShows = selectSettingsNotificationsShows(state); + + const showInColumn = + activeFilter === 'all' + ? notificationShows[notification.type] !== false + : activeFilter === notification.type; + + if (!showInColumn) return; + + if ( + (notification.type === 'mention' || notification.type === 'update') && + notification.status?.filtered + ) { + const filters = notification.status.filtered.filter((result) => + result.filter.context.includes('notifications'), + ); + + if (filters.some((result) => result.filter.filter_action === 'hide')) { + return; + } + } + + dispatchAssociatedRecords(dispatch, [notification]); + + return { + notification, + groupedTypes: selectNotificationGroupedTypes(state), + }; + }, +); + +export const loadPending = createAction('notificationGroups/loadPending'); + +export const updateScrollPosition = createAppAsyncThunk( + 'notificationGroups/updateScrollPosition', + ({ top }: { top: boolean }, { dispatch, getState }) => { + if ( + top && + getState().notificationGroups.mergedNotifications === 'needs-reload' + ) { + void dispatch(fetchNotifications()); + } + + return { top }; + }, +); + +export const setNotificationsFilter = createAppAsyncThunk( + 'notifications/filter/set', + ({ filterType }: { filterType: string }, { dispatch }) => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + void dispatch(fetchNotifications()); + dispatch(saveSettings()); + }, +); + +export const clearNotifications = createDataLoadingThunk( + 'notifications/clear', + () => apiClearNotifications(), +); + +export const markNotificationsAsRead = createAction( + 'notificationGroups/markAsRead', +); + +export const mountNotifications = createAppAsyncThunk( + 'notificationGroups/mount', + (_, { dispatch, getState }) => { + const state = getState(); + + if ( + state.notificationGroups.mounted === 0 && + state.notificationGroups.mergedNotifications === 'needs-reload' + ) { + void dispatch(fetchNotifications()); + } + }, +); + +export const unmountNotifications = createAction('notificationGroups/unmount'); + +export const refreshStaleNotificationGroups = createAppAsyncThunk<{ + deferredRefresh: boolean; +}>('notificationGroups/refreshStale', (_, { dispatch, getState }) => { + const state = getState(); + + if ( + state.notificationGroups.scrolledToTop || + !state.notificationGroups.mounted + ) { + void dispatch(fetchNotifications()); + return { deferredRefresh: false }; + } + + return { deferredRefresh: true }; +}); diff --git a/app/javascript/flavours/glitch/actions/notification_policies.ts b/app/javascript/flavours/glitch/actions/notification_policies.ts index 76452de324..a64175831f 100644 --- a/app/javascript/flavours/glitch/actions/notification_policies.ts +++ b/app/javascript/flavours/glitch/actions/notification_policies.ts @@ -1,3 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; + import { apiGetNotificationPolicy, apiUpdateNotificationsPolicy, @@ -14,3 +16,7 @@ export const updateNotificationsPolicy = createDataLoadingThunk( 'notificationPolicy/update', (policy: Partial) => apiUpdateNotificationsPolicy(policy), ); + +export const decreasePendingRequestsCount = createAction( + 'notificationPolicy/decreasePendingRequestsCount', +); diff --git a/app/javascript/flavours/glitch/actions/notification_requests.ts b/app/javascript/flavours/glitch/actions/notification_requests.ts new file mode 100644 index 0000000000..f53668abd5 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notification_requests.ts @@ -0,0 +1,214 @@ +import { + apiFetchNotificationRequest, + apiFetchNotificationRequests, + apiFetchNotifications, + apiAcceptNotificationRequest, + apiDismissNotificationRequest, + apiAcceptNotificationRequests, + apiDismissNotificationRequests, +} from 'flavours/glitch/api/notifications'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { + ApiNotificationGroupJSON, + ApiNotificationJSON, +} from 'flavours/glitch/api_types/notifications'; +import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; +import type { AppDispatch } from 'flavours/glitch/store'; +import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; + +import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { decreasePendingRequestsCount } from './notification_policies'; + +// TODO: refactor with notification_groups +function dispatchAssociatedRecords( + dispatch: AppDispatch, + notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], +) { + const fetchedAccounts: ApiAccountJSON[] = []; + const fetchedStatuses: ApiStatusJSON[] = []; + + notifications.forEach((notification) => { + if (notification.type === 'admin.report') { + fetchedAccounts.push(notification.report.target_account); + } + + if (notification.type === 'moderation_warning') { + fetchedAccounts.push(notification.moderation_warning.target_account); + } + + if ('status' in notification && notification.status) { + fetchedStatuses.push(notification.status); + } + }); + + if (fetchedAccounts.length > 0) + dispatch(importFetchedAccounts(fetchedAccounts)); + + if (fetchedStatuses.length > 0) + dispatch(importFetchedStatuses(fetchedStatuses)); +} + +export const fetchNotificationRequests = createDataLoadingThunk( + 'notificationRequests/fetch', + async (_params, { getState }) => { + let sinceId = undefined; + + if (getState().notificationRequests.items.length > 0) { + sinceId = getState().notificationRequests.items[0]?.id; + } + + return apiFetchNotificationRequests({ + since_id: sinceId, + }); + }, + ({ requests, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatch(importFetchedAccounts(requests.map((request) => request.account))); + + return { requests, next: next?.uri }; + }, + { + condition: (_params, { getState }) => + !getState().notificationRequests.isLoading, + }, +); + +export const fetchNotificationRequest = createDataLoadingThunk( + 'notificationRequest/fetch', + async ({ id }: { id: string }) => apiFetchNotificationRequest(id), + { + condition: ({ id }, { getState }) => + !( + getState().notificationRequests.current.item?.id === id || + getState().notificationRequests.current.isLoading + ), + }, +); + +export const expandNotificationRequests = createDataLoadingThunk( + 'notificationRequests/expand', + async (_, { getState }) => { + const nextUrl = getState().notificationRequests.next; + if (!nextUrl) throw new Error('missing URL'); + + return apiFetchNotificationRequests(undefined, nextUrl); + }, + ({ requests, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatch(importFetchedAccounts(requests.map((request) => request.account))); + + return { requests, next: next?.uri }; + }, + { + condition: (_, { getState }) => + !!getState().notificationRequests.next && + !getState().notificationRequests.isLoading, + }, +); + +export const fetchNotificationsForRequest = createDataLoadingThunk( + 'notificationRequest/fetchNotifications', + async ({ accountId }: { accountId: string }, { getState }) => { + const sinceId = + // @ts-expect-error current.notifications.items is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + getState().notificationRequests.current.notifications.items[0]?.get( + 'id', + ) as string | undefined; + + return apiFetchNotifications({ + since_id: sinceId, + account_id: accountId, + }); + }, + ({ notifications, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications, next: next?.uri }; + }, + { + condition: ({ accountId }, { getState }) => { + const current = getState().notificationRequests.current; + return !( + current.item?.account_id === accountId && + current.notifications.isLoading + ); + }, + }, +); + +export const expandNotificationsForRequest = createDataLoadingThunk( + 'notificationRequest/expandNotifications', + async (_, { getState }) => { + const nextUrl = getState().notificationRequests.current.notifications.next; + if (!nextUrl) throw new Error('missing URL'); + + return apiFetchNotifications(undefined, nextUrl); + }, + ({ notifications, links }, { dispatch }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications, next: next?.uri }; + }, + { + condition: ({ accountId }: { accountId: string }, { getState }) => { + const url = getState().notificationRequests.current.notifications.next; + + return ( + !!url && + !getState().notificationRequests.current.notifications.isLoading && + getState().notificationRequests.current.item?.account_id === accountId + ); + }, + }, +); + +export const acceptNotificationRequest = createDataLoadingThunk( + 'notificationRequest/accept', + ({ id }: { id: string }) => apiAcceptNotificationRequest(id), + (_data, { dispatch, discardLoadData }) => { + dispatch(decreasePendingRequestsCount(1)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const dismissNotificationRequest = createDataLoadingThunk( + 'notificationRequest/dismiss', + ({ id }: { id: string }) => apiDismissNotificationRequest(id), + (_data, { dispatch, discardLoadData }) => { + dispatch(decreasePendingRequestsCount(1)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const acceptNotificationRequests = createDataLoadingThunk( + 'notificationRequests/acceptBulk', + ({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids), + (_data, { dispatch, discardLoadData, actionArg: { ids } }) => { + dispatch(decreasePendingRequestsCount(ids.length)); + + // The payload is not used in any functions + return discardLoadData; + }, +); + +export const dismissNotificationRequests = createDataLoadingThunk( + 'notificationRequests/dismissBulk', + ({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids), + (_data, { dispatch, discardLoadData, actionArg: { ids } }) => { + dispatch(decreasePendingRequestsCount(ids.length)); + + // The payload is not used in any functions + return discardLoadData; + }, +); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 3a1af501c8..2499b8da1d 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -1,110 +1,31 @@ import { IntlMessageFormat } from 'intl-messageformat'; import { defineMessages } from 'react-intl'; -import { List as ImmutableList } from 'immutable'; - -import { compareId } from 'flavours/glitch/compare_id'; -import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; - -import api, { getLinks } from '../api'; import { unescapeHTML } from '../utils/html'; import { requestNotificationPermission } from '../utils/notifications'; -import { fetchFollowRequests, fetchRelationships } from './accounts'; +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'; - -// tracking the notif cleaning request -export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST'; -export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS'; -export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL'; -export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE'; -export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes -// Unmark notifications (when the cleaning mode is left) -export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE'; -// Mark one for delete -export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE'; - -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_CLEAR = 'NOTIFICATIONS_CLEAR'; -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_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; - -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_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST'; -export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS'; -export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL'; - -export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST'; -export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS'; -export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL'; - -export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST'; -export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS'; -export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL'; - -export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST'; -export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS'; -export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL'; - -export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST'; -export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; -export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; - -export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; -export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; -export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; - -export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST'; -export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS'; -export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL'; - defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, -}); - -const fetchRelatedRelationships = (dispatch, notifications) => { - const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id); - - if (accountIds.length > 0) { - dispatch(fetchRelationships(accountIds)); - } -}; - -export const loadPending = () => ({ - type: NOTIFICATIONS_LOAD_PENDING, + group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, }); 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); @@ -126,27 +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})); - - fetchRelatedRelationships(dispatch, [notification]); - } 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) { @@ -163,230 +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, forceLoad } = {}, done = noOp) { - return (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 { - done(); - 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)); - - api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }).then(response => { - 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)); - fetchRelatedRelationships(dispatch, response.data); - dispatch(submitMarkers()); - }).catch(error => { - dispatch(expandNotificationsFail(error, isLoadingMore)); - }).finally(() => { - done(); - }); - }; -} - -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 clearNotifications() { - return (dispatch) => { - dispatch({ - type: NOTIFICATIONS_CLEAR, - }); - - api().post('/api/v1/notifications/clear'); - }; -} - -export function scrollTopNotifications(top) { - return { - type: NOTIFICATIONS_SCROLL_TOP, - top, - }; -} - -export function deleteMarkedNotifications() { - return (dispatch, getState) => { - dispatch(deleteMarkedNotificationsRequest()); - - let ids = []; - getState().getIn(['notifications', 'items']).forEach((n) => { - if (n.get('markedForDelete')) { - ids.push(n.get('id')); - } - }); - - if (ids.length === 0) { - return; - } - - api().delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => { - dispatch(deleteMarkedNotificationsSuccess()); - }).catch(error => { - console.error(error); - dispatch(deleteMarkedNotificationsFail(error)); - }); - }; -} - -export function enterNotificationClearingMode(yes) { - return { - type: NOTIFICATIONS_ENTER_CLEARING_MODE, - yes: yes, - }; -} - -export function markAllNotifications(yes) { - return { - type: NOTIFICATIONS_MARK_ALL_FOR_DELETE, - yes: yes, // true, false or null. null = invert - }; -} - -export function deleteMarkedNotificationsRequest() { - return { - type: NOTIFICATIONS_DELETE_MARKED_REQUEST, - }; -} - -export function deleteMarkedNotificationsFail() { - return { - type: NOTIFICATIONS_DELETE_MARKED_FAIL, - }; -} - -export function markNotificationForDelete(id, yes) { - return { - type: NOTIFICATION_MARK_FOR_DELETE, - id: id, - yes: yes, - }; -} - -export function deleteMarkedNotificationsSuccess() { - return { - type: NOTIFICATIONS_DELETE_MARKED_SUCCESS, - }; -} - -export function mountNotifications() { - return { - type: NOTIFICATIONS_MOUNT, - }; -} - -export function unmountNotifications() { - return { - type: NOTIFICATIONS_UNMOUNT, - }; -} - -export function notificationsSetVisibility(visibility) { - return { - type: NOTIFICATIONS_SET_VISIBILITY, - visibility: visibility, - }; -} - -export function setFilter (filterType) { - return dispatch => { - dispatch({ - type: NOTIFICATIONS_FILTER_SET, - path: ['notifications', 'quickFilter', 'active'], - value: filterType, - }); - dispatch(expandNotifications({ forceLoad: true })); - dispatch(saveSettings()); - }; -} - -export function markNotificationsAsRead() { - return { - type: NOTIFICATIONS_MARK_AS_READ, - }; -} - // Browser support export function setupBrowserNotifications() { return dispatch => { @@ -429,236 +110,3 @@ export function setBrowserPermission (value) { value, }; } - -export const fetchNotificationRequests = () => (dispatch, getState) => { - const params = {}; - - if (getState().getIn(['notificationRequests', 'isLoading'])) { - return; - } - - if (getState().getIn(['notificationRequests', 'items'])?.size > 0) { - params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']); - } - - dispatch(fetchNotificationRequestsRequest()); - - api().get('/api/v1/notifications/requests', { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(fetchNotificationRequestsFail(err)); - }); -}; - -export const fetchNotificationRequestsRequest = () => ({ - type: NOTIFICATION_REQUESTS_FETCH_REQUEST, -}); - -export const fetchNotificationRequestsSuccess = (requests, next) => ({ - type: NOTIFICATION_REQUESTS_FETCH_SUCCESS, - requests, - next, -}); - -export const fetchNotificationRequestsFail = error => ({ - type: NOTIFICATION_REQUESTS_FETCH_FAIL, - error, -}); - -export const expandNotificationRequests = () => (dispatch, getState) => { - const url = getState().getIn(['notificationRequests', 'next']); - - if (!url || getState().getIn(['notificationRequests', 'isLoading'])) { - return; - } - - dispatch(expandNotificationRequestsRequest()); - - api().get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(expandNotificationRequestsSuccess(response.data, next?.uri)); - }).catch(err => { - dispatch(expandNotificationRequestsFail(err)); - }); -}; - -export const expandNotificationRequestsRequest = () => ({ - type: NOTIFICATION_REQUESTS_EXPAND_REQUEST, -}); - -export const expandNotificationRequestsSuccess = (requests, next) => ({ - type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS, - requests, - next, -}); - -export const expandNotificationRequestsFail = error => ({ - type: NOTIFICATION_REQUESTS_EXPAND_FAIL, - error, -}); - -export const fetchNotificationRequest = id => (dispatch, getState) => { - const current = getState().getIn(['notificationRequests', 'current']); - - if (current.getIn(['item', 'id']) === id || current.get('isLoading')) { - return; - } - - dispatch(fetchNotificationRequestRequest(id)); - - api().get(`/api/v1/notifications/requests/${id}`).then(({ data }) => { - dispatch(fetchNotificationRequestSuccess(data)); - }).catch(err => { - dispatch(fetchNotificationRequestFail(id, err)); - }); -}; - -export const fetchNotificationRequestRequest = id => ({ - type: NOTIFICATION_REQUEST_FETCH_REQUEST, - id, -}); - -export const fetchNotificationRequestSuccess = request => ({ - type: NOTIFICATION_REQUEST_FETCH_SUCCESS, - request, -}); - -export const fetchNotificationRequestFail = (id, error) => ({ - type: NOTIFICATION_REQUEST_FETCH_FAIL, - id, - error, -}); - -export const acceptNotificationRequest = id => (dispatch) => { - dispatch(acceptNotificationRequestRequest(id)); - - api().post(`/api/v1/notifications/requests/${id}/accept`).then(() => { - dispatch(acceptNotificationRequestSuccess(id)); - }).catch(err => { - dispatch(acceptNotificationRequestFail(id, err)); - }); -}; - -export const acceptNotificationRequestRequest = id => ({ - type: NOTIFICATION_REQUEST_ACCEPT_REQUEST, - id, -}); - -export const acceptNotificationRequestSuccess = id => ({ - type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS, - id, -}); - -export const acceptNotificationRequestFail = (id, error) => ({ - type: NOTIFICATION_REQUEST_ACCEPT_FAIL, - id, - error, -}); - -export const dismissNotificationRequest = id => (dispatch) => { - dispatch(dismissNotificationRequestRequest(id)); - - api().post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{ - dispatch(dismissNotificationRequestSuccess(id)); - }).catch(err => { - dispatch(dismissNotificationRequestFail(id, err)); - }); -}; - -export const dismissNotificationRequestRequest = id => ({ - type: NOTIFICATION_REQUEST_DISMISS_REQUEST, - id, -}); - -export const dismissNotificationRequestSuccess = id => ({ - type: NOTIFICATION_REQUEST_DISMISS_SUCCESS, - id, -}); - -export const dismissNotificationRequestFail = (id, error) => ({ - type: NOTIFICATION_REQUEST_DISMISS_FAIL, - id, - error, -}); - -export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { - const current = getState().getIn(['notificationRequests', 'current']); - const params = { account_id: accountId }; - - if (current.getIn(['item', 'account']) === accountId) { - if (current.getIn(['notifications', 'isLoading'])) { - return; - } - - if (current.getIn(['notifications', 'items'])?.size > 0) { - params.since_id = current.getIn(['notifications', 'items', 0, 'id']); - } - } - - dispatch(fetchNotificationsForRequestRequest()); - - api().get('/api/v1/notifications', { params }).then(response => { - 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(fetchNotificationsForRequestSuccess(response.data, next?.uri)); - }).catch(err => { - dispatch(fetchNotificationsForRequestFail(err)); - }); -}; - -export const fetchNotificationsForRequestRequest = () => ({ - type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST, -}); - -export const fetchNotificationsForRequestSuccess = (notifications, next) => ({ - type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS, - notifications, - next, -}); - -export const fetchNotificationsForRequestFail = (error) => ({ - type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL, - error, -}); - -export const expandNotificationsForRequest = () => (dispatch, getState) => { - const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']); - - if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) { - return; - } - - dispatch(expandNotificationsForRequestRequest()); - - api().get(url).then(response => { - 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(expandNotificationsForRequestSuccess(response.data, next?.uri)); - }).catch(err => { - dispatch(expandNotificationsForRequestFail(err)); - }); -}; - -export const expandNotificationsForRequestRequest = () => ({ - type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST, -}); - -export const expandNotificationsForRequestSuccess = (notifications, next) => ({ - type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS, - notifications, - next, -}); - -export const expandNotificationsForRequestFail = (error) => ({ - type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL, - error, -}); diff --git a/app/javascript/flavours/glitch/actions/notifications_typed.ts b/app/javascript/flavours/glitch/actions/notifications_typed.ts index 176362f4b1..a9abfa0e60 100644 --- a/app/javascript/flavours/glitch/actions/notifications_typed.ts +++ b/app/javascript/flavours/glitch/actions/notifications_typed.ts @@ -1,11 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import type { ApiAccountJSON } from '../api_types/accounts'; -// To be replaced once ApiNotificationJSON type exists -interface FakeApiNotificationJSON { - type: string; - account: ApiAccountJSON; -} +import type { ApiNotificationJSON } from 'flavours/glitch/api_types/notifications'; export const notificationsUpdate = createAction( 'notifications/update', @@ -13,8 +8,7 @@ export const notificationsUpdate = createAction( playSound, ...args }: { - notification: FakeApiNotificationJSON; - usePendingItems: boolean; + notification: ApiNotificationJSON; playSound: boolean; }) => ({ payload: args, diff --git a/app/javascript/flavours/glitch/actions/polls.js b/app/javascript/flavours/glitch/actions/polls.js deleted file mode 100644 index aa49341444..0000000000 --- a/app/javascript/flavours/glitch/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/flavours/glitch/actions/polls.ts b/app/javascript/flavours/glitch/actions/polls.ts new file mode 100644 index 0000000000..a50c852fe0 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/polls.ts @@ -0,0 +1,40 @@ +import { apiGetPoll, apiPollVote } from 'flavours/glitch/api/polls'; +import type { ApiPollJSON } from 'flavours/glitch/api_types/polls'; +import { createPollFromServerJSON } from 'flavours/glitch/models/poll'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'flavours/glitch/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/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js index b3d3850e31..647a6bd9fb 100644 --- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js deleted file mode 100644 index 849fc6d33c..0000000000 --- a/app/javascript/flavours/glitch/actions/search.js +++ /dev/null @@ -1,215 +0,0 @@ -import { fromJS } from 'immutable'; - -import { searchHistory } from 'flavours/glitch/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/flavours/glitch/actions/search.ts b/app/javascript/flavours/glitch/actions/search.ts new file mode 100644 index 0000000000..30a03724b1 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/search.ts @@ -0,0 +1,151 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { apiGetSearch } from 'flavours/glitch/api/search'; +import type { ApiSearchType } from 'flavours/glitch/api_types/search'; +import type { + RecentSearch, + SearchType as RecentSearchType, +} from 'flavours/glitch/models/search'; +import { searchHistory } from 'flavours/glitch/settings'; +import { + createDataLoadingThunk, + createAppAsyncThunk, +} from 'flavours/glitch/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 }, { getState }) => { + const signedIn = !!getState().meta.get('me'); + + return apiGetSearch({ + q: url, + resolve: signedIn, + 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/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index c4d292567d..2b6b589b37 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -1,3 +1,5 @@ +import { browserHistory } from 'flavours/glitch/components/router'; + import api from '../api'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; @@ -47,11 +49,13 @@ export function fetchStatusRequest(id, skipLoading) { }; } -export function fetchStatus(id, forceFetch = false) { +export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) { return (dispatch, getState) => { const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; - dispatch(fetchContext(id)); + if (alsoFetchContext) { + dispatch(fetchContext(id)); + } if (skipLoading) { return; @@ -94,7 +98,7 @@ export function redraft(status, raw_text, content_type) { }; } -export const editStatus = (id, routerHistory) => (dispatch, getState) => { +export const editStatus = (id) => (dispatch, getState) => { let status = getState().getIn(['statuses', id]); if (status.get('poll')) { @@ -105,7 +109,7 @@ export const editStatus = (id, routerHistory) => (dispatch, getState) => { api().get(`/api/v1/statuses/${id}/source`).then(response => { dispatch(fetchStatusSourceSuccess()); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type)); }).catch(error => { dispatch(fetchStatusSourceFail(error)); @@ -125,7 +129,7 @@ export const fetchStatusSourceFail = error => ({ error, }); -export function deleteStatus(id, routerHistory, withRedraft = false) { +export function deleteStatus(id, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); @@ -142,7 +146,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) { if (withRedraft) { dispatch(redraft(status, response.data.text, response.data.content_type)); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); } }).catch(error => { dispatch(deleteStatusFail(id, error)); @@ -309,6 +313,21 @@ export function revealStatus(ids) { }; } +export function toggleStatusSpoilers(statusId) { + return (dispatch, getState) => { + const status = getState().statuses.get(statusId); + + if (!status) + return; + + if (status.get('hidden')) { + dispatch(revealStatus(statusId)); + } else { + dispatch(hideStatus(statusId)); + } + }; +} + export function toggleStatusCollapse(id, isCollapsed) { return { type: STATUS_COLLAPSE, @@ -349,3 +368,15 @@ export const undoStatusTranslation = (id, pollId) => ({ id, pollId, }); + +export const navigateToStatus = (statusId) => { + return (_dispatch, getState) => { + const state = getState(); + const accountId = state.statuses.getIn([statusId, 'account']); + const acct = state.accounts.getIn([accountId, 'acct']); + + if (acct) { + browserHistory.push(`/@${acct}/${statusId}`); + } + }; +}; diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js index 4a33d7ef87..b2d19575c4 100644 --- a/app/javascript/flavours/glitch/actions/store.js +++ b/app/javascript/flavours/glitch/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'; @@ -10,7 +10,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()); const applyMigrations = (state) => { return state.withMutations(state => { diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index a55240646f..93bd7dc5f4 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -10,7 +10,8 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; -import { updateNotifications, expandNotifications } from './notifications'; +import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; +import { updateNotifications } from './notifications'; import { updateStatus } from './statuses'; import { updateTimeline, @@ -36,7 +37,7 @@ const randomUpTo = max => * @param {string} channelName * @param {Object.} params * @param {Object} options - * @param {function(Function, Function): void} [options.fallback] + * @param {function(Function, Function): Promise} [options.fallback] * @param {function(): void} [options.fillGaps] * @param {function(object): boolean} [options.accept] * @returns {function(): void} @@ -51,14 +52,13 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti let pollingId; /** - * @param {function(Function, Function): void} fallback + * @param {function(Function, Function): Promise} fallback */ - const useFallback = fallback => { - fallback(dispatch, () => { - // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook - pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); - }); + const useFallback = async fallback => { + await fallback(dispatch, getState); + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook + pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); }; return { @@ -98,10 +98,18 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'delete': dispatch(deleteFromTimelines(data.payload)); break; - case 'notification': + case 'notification': { // @ts-expect-error - dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + const notificationJSON = JSON.parse(data.payload); + dispatch(updateNotifications(notificationJSON, messages, locale)); + // TODO: remove this once the groups feature replaces the previous one + dispatch(processNewNotificationForGroups(notificationJSON)); break; + } + case 'notifications_merged': { + dispatch(refreshStaleNotificationGroups()); + break; + } case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); @@ -125,21 +133,24 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti /** * @param {Function} dispatch - * @param {function(): void} done */ -const refreshHomeTimelineAndNotification = (dispatch, done) => { - // @ts-expect-error - dispatch(expandHomeTimeline({}, () => - // @ts-expect-error - dispatch(expandNotifications({}, () => - dispatch(fetchAnnouncements(done)))))); -}; +async function refreshHomeTimelineAndNotification(dispatch) { + await dispatch(expandHomeTimeline({ maxId: undefined })); + + // TODO: polling for merged notifications + try { + await dispatch(pollRecentGroupNotifications()); + } catch { + // TODO + } + + await dispatch(fetchAnnouncements()); +} /** * @returns {function(): void} */ export const connectUserStream = () => - // @ts-expect-error connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps }); /** diff --git a/app/javascript/flavours/glitch/actions/suggestions.js b/app/javascript/flavours/glitch/actions/suggestions.js deleted file mode 100644 index 258ffa901d..0000000000 --- a/app/javascript/flavours/glitch/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/flavours/glitch/actions/suggestions.ts b/app/javascript/flavours/glitch/actions/suggestions.ts new file mode 100644 index 0000000000..45e151caa0 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/suggestions.ts @@ -0,0 +1,24 @@ +import { + apiGetSuggestions, + apiDeleteSuggestion, +} from 'flavours/glitch/api/suggestions'; +import { createDataLoadingThunk } from 'flavours/glitch/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/flavours/glitch/actions/tags.js b/app/javascript/flavours/glitch/actions/tags.js index d18d7e514f..6e0c95288a 100644 --- a/app/javascript/flavours/glitch/actions/tags.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/tags_typed.ts b/app/javascript/flavours/glitch/actions/tags_typed.ts new file mode 100644 index 0000000000..81ab4ae437 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/tags_typed.ts @@ -0,0 +1,21 @@ +import { + apiGetTag, + apiFollowTag, + apiUnfollowTag, +} from 'flavours/glitch/api/tags'; +import { createDataLoadingThunk } from 'flavours/glitch/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/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index eb5050f152..1d5a696c92 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -87,21 +87,18 @@ export function clearTimeline(timeline) { }; } -const noOp = () => {}; - const parseTags = (tags = {}, mode) => { return (tags[mode] || []).map((tag) => { return tag.value; }); }; -export function expandTimeline(timelineId, path, params = {}, done = noOp) { - return (dispatch, getState) => { +export function expandTimeline(timelineId, path, params = {}) { + return async (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); const isLoadingMore = !!params.max_id; if (timeline.get('isLoading')) { - done(); return; } @@ -120,7 +117,8 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { dispatch(expandTimelineRequest(timelineId, isLoadingMore)); - api().get(path, { params }).then(response => { + try { + const response = await api().get(path, { params }); const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); @@ -138,53 +136,49 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { if (timelineId === 'home') { dispatch(submitMarkers()); } - }).catch(error => { + } catch(error) { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); - }).finally(() => { - done(); - }); + } }; } -export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) { - return (dispatch, getState) => { +export function fillTimelineGaps(timelineId, path, params = {}) { + return async (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); const items = timeline.get('items'); const nullIndexes = items.map((statusId, index) => statusId === null ? index : null); const gaps = nullIndexes.map(index => index > 0 ? items.get(index - 1) : null); // Only expand at most two gaps to avoid doing too many requests - done = gaps.take(2).reduce((done, maxId) => { - return (() => dispatch(expandTimeline(timelineId, path, { ...params, maxId }, done))); - }, done); - - done(); + for (const maxId of gaps.take(2)) { + await dispatch(expandTimeline(timelineId, path, { ...params, maxId })); + } }; } -export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); -export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done); -export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); -export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); +export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }); +export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }); +export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }); +export const expandDirectTimeline = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }); export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId }); export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); -export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); -export const expandLinkTimeline = (url, { maxId } = {}, done = noOp) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }, done); -export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { +export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); +export const expandLinkTimeline = (url, { maxId } = {}) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }); +export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}) => { return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, any: parseTags(tags, 'any'), all: parseTags(tags, 'all'), none: parseTags(tags, 'none'), local: local, - }, done); + }); }; -export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done); -export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }, done); -export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done); -export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done); +export const fillHomeTimelineGaps = () => fillTimelineGaps('home', '/api/v1/timelines/home', {}); +export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }); +export const fillCommunityTimelineGaps = ({ onlyMedia } = {}) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }); +export const fillListTimelineGaps = (id) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}); export function expandTimelineRequest(timeline, isLoadingMore) { return { diff --git a/app/javascript/flavours/glitch/api.ts b/app/javascript/flavours/glitch/api.ts index 24672290c7..f0663ded40 100644 --- a/app/javascript/flavours/glitch/api.ts +++ b/app/javascript/flavours/glitch/api.ts @@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => { // eslint-disable-next-line import/no-default-export export default function api(withAuthorization = true) { return axios.create({ + transitional: { + clarifyTimeoutError: true, + }, headers: { ...csrfHeader, ...(withAuthorization ? authorizationTokenFromInitialState() : {}), @@ -65,8 +68,10 @@ export async function apiRequest( method: Method, url: string, args: { + signal?: AbortSignal; params?: RequestParamsOrData; data?: RequestParamsOrData; + timeout?: number; } = {}, ) { const { data } = await api().request({ diff --git a/app/javascript/flavours/glitch/api/accounts.ts b/app/javascript/flavours/glitch/api/accounts.ts index 410f3d20e3..4d68885088 100644 --- a/app/javascript/flavours/glitch/api/accounts.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api/compose.ts b/app/javascript/flavours/glitch/api/compose.ts new file mode 100644 index 0000000000..1801ccd676 --- /dev/null +++ b/app/javascript/flavours/glitch/api/compose.ts @@ -0,0 +1,7 @@ +import { apiRequestPut } from 'flavours/glitch/api'; +import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments'; + +export const apiUpdateMedia = ( + id: string, + params?: { description?: string; focus?: string }, +) => apiRequestPut(`v1/media/${id}`, params); diff --git a/app/javascript/flavours/glitch/api/instance.ts b/app/javascript/flavours/glitch/api/instance.ts new file mode 100644 index 0000000000..16a2ad04cb --- /dev/null +++ b/app/javascript/flavours/glitch/api/instance.ts @@ -0,0 +1,11 @@ +import { apiRequestGet } from 'flavours/glitch/api'; +import type { + ApiTermsOfServiceJSON, + ApiPrivacyPolicyJSON, +} from 'flavours/glitch/api_types/instance'; + +export const apiGetTermsOfService = () => + apiRequestGet('v1/instance/terms_of_service'); + +export const apiGetPrivacyPolicy = () => + apiRequestGet('v1/instance/privacy_policy'); diff --git a/app/javascript/flavours/glitch/api/lists.ts b/app/javascript/flavours/glitch/api/lists.ts new file mode 100644 index 0000000000..64a0af01f5 --- /dev/null +++ b/app/javascript/flavours/glitch/api/lists.ts @@ -0,0 +1,32 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'flavours/glitch/api'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { ApiListJSON } from 'flavours/glitch/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/flavours/glitch/api/notification_policies.ts b/app/javascript/flavours/glitch/api/notification_policies.ts index e52ea64f41..48b78f96fc 100644 --- a/app/javascript/flavours/glitch/api/notification_policies.ts +++ b/app/javascript/flavours/glitch/api/notification_policies.ts @@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'flavours/glitch/api'; import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies'; export const apiGetNotificationPolicy = () => - apiRequestGet('/v1/notifications/policy'); + apiRequestGet('v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => apiRequestPut('/v1/notifications/policy', policy); +) => apiRequestPut('v2/notifications/policy', policy); diff --git a/app/javascript/flavours/glitch/api/notifications.ts b/app/javascript/flavours/glitch/api/notifications.ts new file mode 100644 index 0000000000..9453f545e6 --- /dev/null +++ b/app/javascript/flavours/glitch/api/notifications.ts @@ -0,0 +1,96 @@ +import api, { + apiRequest, + getLinks, + apiRequestGet, + apiRequestPost, +} from 'flavours/glitch/api'; +import type { + ApiNotificationGroupsResultJSON, + ApiNotificationRequestJSON, + ApiNotificationJSON, +} from 'flavours/glitch/api_types/notifications'; + +export const apiFetchNotifications = async ( + params?: { + account_id?: string; + since_id?: string; + }, + url?: string, +) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/notifications', + params, + }); + + return { + notifications: response.data, + links: getLinks(response), + }; +}; + +export const apiFetchNotificationGroups = async (params?: { + url?: string; + grouped_types?: string[]; + exclude_types?: string[]; + max_id?: string; + since_id?: string; +}) => { + const response = await api().request({ + method: 'GET', + url: '/api/v2/notifications', + params, + }); + + const { statuses, accounts, notification_groups } = response.data; + + return { + statuses, + accounts, + notifications: notification_groups, + links: getLinks(response), + }; +}; + +export const apiClearNotifications = () => + apiRequest('POST', 'v1/notifications/clear'); + +export const apiFetchNotificationRequests = async ( + params?: { + since_id?: string; + }, + url?: string, +) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/notifications/requests', + params, + }); + + return { + requests: response.data, + links: getLinks(response), + }; +}; + +export const apiFetchNotificationRequest = async (id: string) => { + return apiRequestGet( + `v1/notifications/requests/${id}`, + ); +}; + +export const apiAcceptNotificationRequest = async (id: string) => { + return apiRequestPost(`v1/notifications/requests/${id}/accept`); +}; + +export const apiDismissNotificationRequest = async (id: string) => { + return apiRequestPost(`v1/notifications/requests/${id}/dismiss`); +}; + +export const apiAcceptNotificationRequests = async (id: string[]) => { + return apiRequestPost('v1/notifications/requests/accept', { id }); +}; + +export const apiDismissNotificationRequests = async (id: string[]) => { + return apiRequestPost('v1/notifications/requests/dismiss', { id }); +}; diff --git a/app/javascript/flavours/glitch/api/polls.ts b/app/javascript/flavours/glitch/api/polls.ts new file mode 100644 index 0000000000..e4a31d8b80 --- /dev/null +++ b/app/javascript/flavours/glitch/api/polls.ts @@ -0,0 +1,10 @@ +import { apiRequestGet, apiRequestPost } from 'flavours/glitch/api'; +import type { ApiPollJSON } from 'flavours/glitch/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/flavours/glitch/api/search.ts b/app/javascript/flavours/glitch/api/search.ts new file mode 100644 index 0000000000..1b089d5f7e --- /dev/null +++ b/app/javascript/flavours/glitch/api/search.ts @@ -0,0 +1,16 @@ +import { apiRequestGet } from 'flavours/glitch/api'; +import type { + ApiSearchType, + ApiSearchResultsJSON, +} from 'flavours/glitch/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/flavours/glitch/api/suggestions.ts b/app/javascript/flavours/glitch/api/suggestions.ts new file mode 100644 index 0000000000..425f02d541 --- /dev/null +++ b/app/javascript/flavours/glitch/api/suggestions.ts @@ -0,0 +1,8 @@ +import { apiRequestGet, apiRequestDelete } from 'flavours/glitch/api'; +import type { ApiSuggestionJSON } from 'flavours/glitch/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/flavours/glitch/api/tags.ts b/app/javascript/flavours/glitch/api/tags.ts new file mode 100644 index 0000000000..dda8e57bcd --- /dev/null +++ b/app/javascript/flavours/glitch/api/tags.ts @@ -0,0 +1,11 @@ +import { apiRequestPost, apiRequestGet } from 'flavours/glitch/api'; +import type { ApiHashtagJSON } from 'flavours/glitch/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/flavours/glitch/api_types/accounts.ts b/app/javascript/flavours/glitch/api_types/accounts.ts index 5bf3e64288..3f8b27497f 100644 --- a/app/javascript/flavours/glitch/api_types/accounts.ts +++ b/app/javascript/flavours/glitch/api_types/accounts.ts @@ -13,13 +13,13 @@ export interface ApiAccountRoleJSON { } // See app/serializers/rest/account_serializer.rb -export interface ApiAccountJSON { +export interface BaseApiAccountJSON { acct: string; avatar: string; avatar_static: string; bot: boolean; created_at: string; - discoverable: boolean; + discoverable?: boolean; indexable: boolean; display_name: string; emojis: ApiCustomEmojiJSON[]; @@ -45,3 +45,12 @@ export interface ApiAccountJSON { memorial?: boolean; hide_collections: boolean; } + +// See app/serializers/rest/muted_account_serializer.rb +export interface ApiMutedAccountJSON extends BaseApiAccountJSON { + mute_expires_at?: string | null; +} + +// For now, we have the same type representing both `Account` and `MutedAccount` +// objects, but we should refactor this in the future. +export type ApiAccountJSON = ApiMutedAccountJSON; diff --git a/app/javascript/flavours/glitch/api_types/instance.ts b/app/javascript/flavours/glitch/api_types/instance.ts new file mode 100644 index 0000000000..ead9774515 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/instance.ts @@ -0,0 +1,9 @@ +export interface ApiTermsOfServiceJSON { + updated_at: string; + content: string; +} + +export interface ApiPrivacyPolicyJSON { + updated_at: string; + content: string; +} diff --git a/app/javascript/flavours/glitch/api_types/lists.ts b/app/javascript/flavours/glitch/api_types/lists.ts new file mode 100644 index 0000000000..6984cf9b19 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/notification_policies.ts b/app/javascript/flavours/glitch/api_types/notification_policies.ts index 0f4a2d132e..1c3970782c 100644 --- a/app/javascript/flavours/glitch/api_types/notification_policies.ts +++ b/app/javascript/flavours/glitch/api_types/notification_policies.ts @@ -1,10 +1,13 @@ // See app/serializers/rest/notification_policy_serializer.rb +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + export interface NotificationPolicyJSON { - filter_not_following: boolean; - filter_not_followers: boolean; - filter_new_accounts: boolean; - filter_private_mentions: boolean; + for_not_following: NotificationPolicyValue; + for_not_followers: NotificationPolicyValue; + for_new_accounts: NotificationPolicyValue; + for_private_mentions: NotificationPolicyValue; + for_limited_accounts: NotificationPolicyValue; summary: { pending_requests_count: number; pending_notifications_count: number; diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts new file mode 100644 index 0000000000..d173083dbd --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -0,0 +1,172 @@ +// See app/serializers/rest/notification_group_serializer.rb + +import type { AccountWarningAction } from 'flavours/glitch/models/notification_group'; + +import type { ApiAccountJSON } from './accounts'; +import type { ApiReportJSON } from './reports'; +import type { ApiStatusJSON } from './statuses'; + +// See app/model/notification.rb +export const allNotificationTypes = [ + 'follow', + 'follow_request', + 'favourite', + 'reblog', + 'mention', + 'poll', + 'status', + 'update', + 'admin.sign_up', + 'admin.report', + 'moderation_warning', + 'severed_relationships', + 'annual_report', +]; + +export type NotificationWithStatusType = + | 'favourite' + | 'reblog' + | 'status' + | 'mention' + | 'poll' + | 'update'; + +export type NotificationType = + | NotificationWithStatusType + | 'follow' + | 'follow_request' + | 'moderation_warning' + | 'severed_relationships' + | 'admin.sign_up' + | 'admin.report' + | 'annual_report'; + +export interface BaseNotificationJSON { + id: string; + type: NotificationType; + created_at: string; + group_key: string; + account: ApiAccountJSON; +} + +export interface BaseNotificationGroupJSON { + group_key: string; + notifications_count: number; + type: NotificationType; + sample_account_ids: string[]; + latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly + most_recent_notification_id: string; + page_min_id?: string; + page_max_id?: string; +} + +interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { + type: NotificationWithStatusType; + status_id: string | null; +} + +interface NotificationWithStatusJSON extends BaseNotificationJSON { + type: NotificationWithStatusType; + status: ApiStatusJSON | null; +} + +interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +interface ReportNotificationJSON extends BaseNotificationJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up'; +interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON { + type: SimpleNotificationTypes; +} + +interface SimpleNotificationJSON extends BaseNotificationJSON { + type: SimpleNotificationTypes; +} + +export interface ApiAccountWarningJSON { + id: string; + action: AccountWarningAction; + text: string; + status_ids: string[]; + created_at: string; + target_account: ApiAccountJSON; + appeal: unknown; +} + +interface ModerationWarningNotificationGroupJSON + extends BaseNotificationGroupJSON { + type: 'moderation_warning'; + moderation_warning: ApiAccountWarningJSON; +} + +interface ModerationWarningNotificationJSON extends BaseNotificationJSON { + type: 'moderation_warning'; + moderation_warning: ApiAccountWarningJSON; +} + +export interface ApiAccountRelationshipSeveranceEventJSON { + id: string; + type: 'account_suspension' | 'domain_block' | 'user_domain_block'; + purged: boolean; + target_name: string; + followers_count: number; + following_count: number; + created_at: string; +} + +interface AccountRelationshipSeveranceNotificationGroupJSON + extends BaseNotificationGroupJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +interface AccountRelationshipSeveranceNotificationJSON + extends BaseNotificationJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +export interface ApiAnnualReportEventJSON { + year: string; +} + +interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'annual_report'; + annual_report: ApiAnnualReportEventJSON; +} + +export type ApiNotificationJSON = + | SimpleNotificationJSON + | ReportNotificationJSON + | AccountRelationshipSeveranceNotificationJSON + | NotificationWithStatusJSON + | ModerationWarningNotificationJSON; + +export type ApiNotificationGroupJSON = + | SimpleNotificationGroupJSON + | ReportNotificationGroupJSON + | AccountRelationshipSeveranceNotificationGroupJSON + | NotificationGroupWithStatusJSON + | ModerationWarningNotificationGroupJSON + | AnnualReportNotificationGroupJSON; + +export interface ApiNotificationGroupsResultJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + notification_groups: ApiNotificationGroupJSON[]; +} + +export interface ApiNotificationRequestJSON { + id: string; + created_at: string; + updated_at: string; + notifications_count: string; + account: ApiAccountJSON; + last_status?: ApiStatusJSON; +} diff --git a/app/javascript/flavours/glitch/api_types/polls.ts b/app/javascript/flavours/glitch/api_types/polls.ts index 8181f7b813..275ca29fd7 100644 --- a/app/javascript/flavours/glitch/api_types/polls.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/reports.ts b/app/javascript/flavours/glitch/api_types/reports.ts new file mode 100644 index 0000000000..b11cfdd2eb --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/reports.ts @@ -0,0 +1,16 @@ +import type { ApiAccountJSON } from './accounts'; + +export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation'; + +export interface ApiReportJSON { + id: string; + action_taken: unknown; + action_taken_at: unknown; + category: ReportCategory; + comment: string; + forwarded: boolean; + created_at: string; + status_ids: string[]; + rule_ids: string[]; + target_account: ApiAccountJSON; +} diff --git a/app/javascript/flavours/glitch/api_types/search.ts b/app/javascript/flavours/glitch/api_types/search.ts new file mode 100644 index 0000000000..795cbb2b41 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts index 9de86e7fa6..c84ecc7afc 100644 --- a/app/javascript/flavours/glitch/api_types/statuses.ts +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -58,6 +58,29 @@ export interface ApiPreviewCardJSON { authors: ApiPreviewCardAuthorJSON[]; } +export type FilterContext = + | 'home' + | 'notifications' + | 'public' + | 'thread' + | 'account'; + +export interface ApiFilterJSON { + id: string; + title: string; + context: FilterContext; + expires_at: string; + filter_action: 'warn' | 'hide'; + keywords?: unknown[]; // TODO: FilterKeywordSerializer + statuses?: unknown[]; // TODO: FilterStatusSerializer +} + +export interface ApiFilterResultJSON { + filter: ApiFilterJSON; + keyword_matches: string[]; + status_matches: string[]; +} + export interface ApiStatusJSON { id: string; created_at: string; @@ -80,8 +103,7 @@ export interface ApiStatusJSON { bookmarked?: boolean; pinned?: boolean; - // filtered: FilterResult[] - filtered: unknown; // TODO + filtered?: ApiFilterResultJSON[]; content?: string; text?: string; diff --git a/app/javascript/flavours/glitch/api_types/suggestions.ts b/app/javascript/flavours/glitch/api_types/suggestions.ts new file mode 100644 index 0000000000..70d0ed8e7d --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/suggestions.ts @@ -0,0 +1,13 @@ +import type { ApiAccountJSON } from 'flavours/glitch/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/flavours/glitch/api_types/tags.ts b/app/javascript/flavours/glitch/api_types/tags.ts new file mode 100644 index 0000000000..0c16c8bd28 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/common.js b/app/javascript/flavours/glitch/common.js index 1bcb1d00f1..71f45dbf3b 100644 --- a/app/javascript/flavours/glitch/common.js +++ b/app/javascript/flavours/glitch/common.js @@ -1,12 +1,11 @@ import Rails from '@rails/ujs'; -import 'font-awesome/css/font-awesome.css'; export function start() { require.context('@/images/', true, /\.(jpg|png|svg)$/); try { Rails.start(); - } catch (e) { + } catch { // If called twice } } diff --git a/app/javascript/flavours/glitch/components/account.jsx b/app/javascript/flavours/glitch/components/account.jsx deleted file mode 100644 index 038009cfa8..0000000000 --- a/app/javascript/flavours/glitch/components/account.jsx +++ /dev/null @@ -1,183 +0,0 @@ -import PropTypes from 'prop-types'; -import { useCallback } from 'react'; - -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import { EmptyAccount } from 'flavours/glitch/components/empty_account'; -import { ShortNumber } from 'flavours/glitch/components/short_number'; -import { VerifiedBadge } from 'flavours/glitch/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 { Permalink } from './permalink'; -import { RelativeTimestamp } from './relative_timestamp'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' }, - unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' }, - mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, - block: { id: 'account.block_short', defaultMessage: 'Block' }, - more: { id: 'status.more', defaultMessage: 'More' }, -}); - -const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => { - const intl = useIntl(); - - const handleFollow = useCallback(() => { - onFollow(account); - }, [onFollow, account]); - - const handleBlock = useCallback(() => { - onBlock(account); - }, [onBlock, account]); - - const handleMute = useCallback(() => { - onMute(account); - }, [onMute, account]); - - const handleMuteNotifications = useCallback(() => { - onMuteNotifications(account, true); - }, [onMuteNotifications, account]); - - const handleUnmuteNotifications = useCallback(() => { - onMuteNotifications(account, false); - }, [onMuteNotifications, account]); - - if (!account) { - return ; - } - - if (hidden) { - return ( - <> - {account.get('display_name')} - {account.get('username')} - - ); - } - - let buttons; - - if (account.get('id') !== me && account.get('relationship', null) !== null) { - const following = account.getIn(['relationship', 'following']); - const requested = account.getIn(['relationship', 'requested']); - const blocking = account.getIn(['relationship', 'blocking']); - const muting = account.getIn(['relationship', 'muting']); - - if (requested) { - buttons = + + + {({ props }) => ( +

+
+

+ +

+

{description}

+
+
+ )} + + + ); +}; diff --git a/app/javascript/flavours/glitch/components/attachment_list.jsx b/app/javascript/flavours/glitch/components/attachment_list.jsx index 60800f41ec..3cdd409e21 100644 --- a/app/javascript/flavours/glitch/components/attachment_list.jsx +++ b/app/javascript/flavours/glitch/components/attachment_list.jsx @@ -36,7 +36,7 @@ export default class AttachmentList extends ImmutablePureComponent { return (
  • - + {compact && } {compact && ' ' } {displayUrl ? filename(displayUrl) : } diff --git a/app/javascript/flavours/glitch/components/avatar.tsx b/app/javascript/flavours/glitch/components/avatar.tsx index c45c26981f..bb146f0edb 100644 --- a/app/javascript/flavours/glitch/components/avatar.tsx +++ b/app/javascript/flavours/glitch/components/avatar.tsx @@ -1,16 +1,19 @@ +import { useState, useCallback } from 'react'; + import classNames from 'classnames'; +import { useHovering } from 'flavours/glitch/hooks/useHovering'; +import { autoPlayGif } from 'flavours/glitch/initial_state'; import type { Account } from 'flavours/glitch/models/account'; -import { useHovering } from '../hooks/useHovering'; -import { autoPlayGif } from '../initial_state'; - interface Props { account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there size: number; style?: React.CSSProperties; inline?: boolean; animate?: boolean; + counter?: number | string; + counterBorderColor?: string; } export const Avatar: React.FC = ({ @@ -19,8 +22,12 @@ export const Avatar: React.FC = ({ size = 20, inline = false, style: styleFromParent, + counter, + counterBorderColor, }) => { const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); const style = { ...styleFromParent, @@ -33,17 +40,37 @@ export const Avatar: React.FC = ({ ? account?.get('avatar') : account?.get('avatar_static'); + const handleLoad = useCallback(() => { + setLoading(false); + }, [setLoading]); + + const handleError = useCallback(() => { + setError(true); + }, [setError]); + return (
    - {src && {account?.get('acct')}} + {src && !error && ( + + )} + + {counter && ( +
    + {counter} +
    + )}
    ); }; diff --git a/app/javascript/flavours/glitch/components/button.tsx b/app/javascript/flavours/glitch/components/button.tsx index c76aaea42f..a527468f65 100644 --- a/app/javascript/flavours/glitch/components/button.tsx +++ b/app/javascript/flavours/glitch/components/button.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren } from 'react'; +import type { PropsWithChildren, JSX } from 'react'; import { useCallback } from 'react'; import classNames from 'classnames'; @@ -7,6 +7,8 @@ interface BaseProps extends Omit, 'children'> { block?: boolean; secondary?: boolean; + compact?: boolean; + dangerous?: boolean; } interface PropsChildren extends PropsWithChildren { @@ -26,6 +28,8 @@ export const Button: React.FC = ({ disabled, block, secondary, + compact, + dangerous, className, title, text, @@ -45,7 +49,9 @@ export const Button: React.FC = ({ + )} + + ); +}; diff --git a/app/javascript/flavours/glitch/components/content_warning.tsx b/app/javascript/flavours/glitch/components/content_warning.tsx new file mode 100644 index 0000000000..a261719d2d --- /dev/null +++ b/app/javascript/flavours/glitch/components/content_warning.tsx @@ -0,0 +1,25 @@ +import type { IconName } from './media_icon'; +import { MediaIcon } from './media_icon'; +import { StatusBanner, BannerVariant } from './status_banner'; + +export const ContentWarning: React.FC<{ + text: string; + expanded?: boolean; + onClick?: () => void; + icons?: IconName[]; +}> = ({ text, expanded, onClick, icons }) => ( + + {icons?.map((icon) => ( + + ))} +

    + +); diff --git a/app/javascript/flavours/glitch/components/copy_paste_text.tsx b/app/javascript/flavours/glitch/components/copy_paste_text.tsx new file mode 100644 index 0000000000..053654b6e0 --- /dev/null +++ b/app/javascript/flavours/glitch/components/copy_paste_text.tsx @@ -0,0 +1,90 @@ +import { useRef, useState, useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; +import { useTimeout } from 'flavours/glitch/hooks/useTimeout'; + +export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => { + const inputRef = useRef(null); + const [copied, setCopied] = useState(false); + const [focused, setFocused] = useState(false); + const [setAnimationTimeout] = useTimeout(); + + const handleInputClick = useCallback(() => { + setCopied(false); + + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + inputRef.current.setSelectionRange(0, value.length); + } + }, [setCopied, value]); + + const handleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + void navigator.clipboard.writeText(value); + inputRef.current?.blur(); + setCopied(true); + setAnimationTimeout(() => { + setCopied(false); + }, 700); + }, + [setCopied, setAnimationTimeout, value], + ); + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== ' ') return; + void navigator.clipboard.writeText(value); + setCopied(true); + setAnimationTimeout(() => { + setCopied(false); + }, 700); + }, + [setCopied, setAnimationTimeout, value], + ); + + const handleFocus = useCallback(() => { + setFocused(true); + }, [setFocused]); + + const handleBlur = useCallback(() => { + setFocused(false); + }, [setFocused]); + + return ( +

    +