diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3aa0bbf7da4ec3..ed8484f5b80b26 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,7 +9,7 @@ RUN /bin/bash --login -i -c "nvm install" # Install additional OS packages RUN apt-get update && \ export DEBIAN_FRONTEND=noninteractive && \ - apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev + apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg libvips42 libpam-dev # Disable download prompt for Corepack ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 0c7ead1c15f1bd..3c9e06116cb913 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version-file: '.nvmrc' @@ -23,7 +23,7 @@ runs: shell: bash run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index 3e232f134c9422..0085aa875cb7d8 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -17,7 +17,7 @@ runs: sudo apt-get install -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: ruby-version: ${{ inputs.ruby-version }} bundler-cache: true diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 678a256b748e16..ddca0bc239cd78 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -113,6 +113,7 @@ ], matchUpdateTypes: ['major'], groupName: 'artifact actions (major)', + extends: ['helpers:pinGitHubActionDigests'], }, { // Update @types/* packages every week, with one grouped PR diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 84b729df434f74..595011a0c6f9d6 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -35,7 +35,7 @@ jobs: - linux/arm64 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Prepare env: @@ -47,19 +47,19 @@ jobs: image_names=${PUSH_TO_IMAGES//$'\n'/,} echo "IMAGE_NAMES=${image_names%,}" >> $GITHUB_ENV - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 id: buildx - name: Log in to Docker Hub if: contains(inputs.push_to_images, 'tootsuite') - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # 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 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,7 +67,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 if: ${{ inputs.push_to_images != '' }} with: images: ${{ inputs.push_to_images }} @@ -76,7 +76,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 with: context: . file: ${{ inputs.file_to_build }} @@ -100,7 +100,7 @@ jobs: - name: Upload digest if: ${{ inputs.push_to_images != '' }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: # `hashFiles` is used to disambiguate between streaming and non-streaming images name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }} @@ -119,10 +119,10 @@ jobs: PUSH_TO_IMAGES: ${{ inputs.push_to_images }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download digests - uses: actions/download-artifact@v6 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: ${{ runner.temp }}/digests # `hashFiles` is used to disambiguate between streaming and non-streaming images @@ -131,25 +131,25 @@ jobs: - name: Log in to Docker Hub if: contains(inputs.push_to_images, 'tootsuite') - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # 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 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 if: ${{ inputs.push_to_images != '' }} with: images: ${{ inputs.push_to_images }} diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index f6ff65217535df..e9953b9ffec86a 100644 --- a/.github/workflows/build-push-pr.yml +++ b/.github/workflows/build-push-pr.yml @@ -18,7 +18,7 @@ jobs: steps: # Repository needs to be cloned so `git rev-parse` below works - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - id: version_vars run: | echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index 818e3fe4eb0dbc..2634bbfe4423d0 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -7,7 +7,44 @@ permissions: packages: write jobs: + check-latest-stable: + runs-on: ubuntu-latest + outputs: + latest: ${{ steps.check.outputs.is_latest_stable }} + steps: + # Repository needs to be cloned to list branches + - name: Clone repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Check latest stable + shell: bash + id: check + run: | + ref="${GITHUB_REF#refs/tags/}" + + if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then + current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + else + echo "tag $ref is not semver" + echo "is_latest_stable=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \ + | sed -E 's#^origin/stable-##' \ + | sort -Vr \ + | head -n1) + + if [[ "$current" == "$latest" ]]; then + echo "is_latest_stable=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest_stable=false" >> "$GITHUB_OUTPUT" + fi + build-image: + needs: check-latest-stable uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile @@ -19,13 +56,14 @@ 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.5.') }} + latest=${{ needs.check-latest-stable.outputs.latest }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} secrets: inherit build-image-streaming: + needs: check-latest-stable uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile @@ -37,7 +75,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.5.') }} + latest=${{ needs.check-latest-stable.outputs.latest }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index 9cc49a7f79715f..f3a97f8a2ea77c 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -28,10 +28,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: bundler-cache: true diff --git a/.github/workflows/bundlesize-compare.yml b/.github/workflows/bundlesize-compare.yml new file mode 100644 index 00000000000000..d1b91aed9612a1 --- /dev/null +++ b/.github/workflows/bundlesize-compare.yml @@ -0,0 +1,73 @@ +name: Compare JS bundle size +on: + pull_request: + paths: + - 'app/javascript/**' + - 'vite.config.mts' + - 'package.json' + - 'yarn.lock' + - .github/workflows/bundlesize-compare.yml + +jobs: + build-head: + name: 'Build head' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: head-stats + path: ./stats.json + if-no-files-found: error + + build-base: + name: 'Build base' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.base_ref }} + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: base-stats + path: ./stats.json + if-no-files-found: error + + compare: + name: 'Compare base & head bundle sizes' + runs-on: ubuntu-latest + needs: [build-base, build-head] + permissions: + pull-requests: write + steps: + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + + - uses: twk3/rollup-size-compare-action@a1f8628fee0e40899ab2b46c1b6e14552b99281e # v1.2.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + current-stats-json-path: ./head-stats/stats.json + base-stats-json-path: ./base-stats/stats.json diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index 9d500ffc44e2e6..4906ab6cf45c6d 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -42,8 +42,7 @@ jobs: - name: Check for missing strings in English YML run: | - bin/i18n-tasks add-missing -l en - git diff --exit-code + bin/i18n-tasks missing -t used -l en - name: Check for wrong string interpolations run: bin/i18n-tasks check-consistent-interpolations diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index e0383b83bcc0a9..19548c28c1355e 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -1,31 +1,51 @@ name: 'Chromatic' +permissions: + contents: read on: push: branches-ignore: - renovate/* - stable-* - paths: - - 'package.json' - - 'yarn.lock' - - '**/*.js' - - '**/*.jsx' - - '**/*.ts' - - '**/*.tsx' - - '**/*.css' - - '**/*.scss' - - '.github/workflows/chromatic.yml' jobs: + pathcheck: + name: Check for relevant changes + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.filter.outputs.src }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + id: filter + with: + filters: | + src: + - 'package.json' + - 'yarn.lock' + - '**/*.js' + - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' + - '**/*.css' + - '**/*.scss' + - '.github/workflows/chromatic.yml' + chromatic: name: Run Chromatic runs-on: ubuntu-latest - if: github.repository == 'mastodon/mastodon' + needs: pathcheck + if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true' steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -33,9 +53,10 @@ jobs: run: yarn build-storybook - name: Run Chromatic - uses: chromaui/action@v13 + uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13 with: - # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} zip: true storybookBuildDir: 'storybook-static' + exitOnceUploaded: true # Exit immediately after upload + autoAcceptChanges: 'main' # Auto-accept changes on main branch only diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cf038ae4809b78..4e55219f5565aa 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,11 +31,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -48,7 +48,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -61,6 +61,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 8a6ac6df277dcc..297e1c2f3c3a41 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Increase Git http.postBuffer # This is needed due to a bug in Ubuntu's cURL version? @@ -24,7 +24,7 @@ jobs: # Download the translation files from Crowdin - name: crowdin action - uses: crowdin/github-action@v2 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2 with: upload_sources: false upload_translations: false @@ -50,7 +50,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v7.0.8 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 89125fb2db4633..1851b26a3a55f6 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Increase Git http.postBuffer # This is needed due to a bug in Ubuntu's cURL version? @@ -26,7 +26,7 @@ jobs: # Download the translation files from Crowdin - name: crowdin action - uses: crowdin/github-action@v2 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2 with: upload_sources: false upload_translations: false @@ -52,7 +52,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 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 e2764b1ec75513..faf01e41b4acbd 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: crowdin action - uses: crowdin/github-action@v2 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2 with: upload_sources: true upload_translations: false diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 6803686b4ff6c0..528813c73a55de 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 51a78d679fcaa7..178342ed3441f7 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index d3452c9ffc295f..637c211700e2bb 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -33,10 +33,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: bundler-cache: true diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index c9ba1a13f17678..7036c5e5698f1e 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index e73617d85dace6..f99362895a5072 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -35,15 +35,15 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: bundler-cache: true - name: Set-up RuboCop Problem Matcher - uses: r7kamura/rubocop-problem-matchers-action@v1 + uses: r7kamura/rubocop-problem-matchers-action@59f1a0759f50cc2649849fd850b8487594bb5a81 # v1.2.2 - name: Run rubocop run: bin/rubocop diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index f0fc8b0db729e3..cf594c2c6c4da9 100644 --- a/.github/workflows/rebase-needed.yml +++ b/.github/workflows/rebase-needed.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Check for merge conflicts - uses: eps1lon/actions-label-merge-conflict@v3 + uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3 with: dirtyLabel: 'rebase needed :construction:' repoToken: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index b8e1cc89aaa632..d1b55a5e0b9d90 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index b1b94692f0c75c..f33bf981cc4486 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -72,7 +72,7 @@ jobs: BUNDLE_RETRY: 3 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 8f05812d600505..97d7cf1800e054 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -32,7 +32,7 @@ jobs: SECRET_KEY_BASE_DUMMY: 1 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -43,7 +43,7 @@ jobs: onlyProduction: 'true' - name: Cache assets from compilation - uses: actions/cache@v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | public/assets @@ -65,7 +65,7 @@ jobs: run: | tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: matrix.mode == 'test' with: path: |- @@ -128,9 +128,9 @@ jobs: - '3.3' - '.ruby-version' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -151,7 +151,7 @@ jobs: bin/flatware fan bin/rails db:test:prepare - name: Cache RSpec persistence file - uses: actions/cache@v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | tmp/rspec/examples.txt @@ -167,99 +167,12 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 with: files: coverage/lcov/*.lcov env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-imagemagick: - name: ImageMagick tests - runs-on: ubuntu-latest - - needs: - - build - - services: - postgres: - image: postgres:14-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10ms - --health-timeout 3s - --health-retries 50 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10ms - --health-timeout 3s - --health-retries 50 - ports: - - 6379:6379 - - env: - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} - RAILS_ENV: test - ALLOW_NOPAM: true - PAM_ENABLED: true - PAM_DEFAULT_SERVICE: pam_test - PAM_CONTROLLED_SERVICE: pam_test_controlled - OIDC_ENABLED: true - OIDC_SCOPE: read - SAML_ENABLED: true - CAS_ENABLED: true - BUNDLE_WITH: 'pam_authentication test' - GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} - MASTODON_USE_LIBVIPS: false - - strategy: - fail-fast: false - matrix: - ruby-version: - - '3.2' - - '3.3' - - '.ruby-version' - steps: - - uses: actions/checkout@v5 - - - uses: actions/download-artifact@v6 - with: - path: './' - name: ${{ github.sha }} - - - name: Expand archived asset artifacts - run: | - tar xvzf artifacts.tar.gz - - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby - with: - ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick libpam-dev - - - name: Load database schema - run: './bin/rails db:create db:schema:load db:seed' - - - run: bin/rspec --tag attachment_processing - - - name: Upload coverage reports to Codecov - if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v5 - with: - files: coverage/lcov/mastodon.lcov - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-e2e: name: End to End testing runs-on: ubuntu-latest @@ -309,9 +222,9 @@ jobs: - '.ruby-version' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -334,7 +247,7 @@ jobs: - name: Cache Playwright Chromium browser id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.cache/ms-playwright key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -350,14 +263,14 @@ jobs: - run: bin/rspec spec/system --tag streaming --tag js - name: Archive logs - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: e2e-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: e2e-screenshots-${{ matrix.ruby-version }} @@ -447,9 +360,9 @@ jobs: search-image: opensearchproject/opensearch:2 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -469,14 +382,14 @@ jobs: - run: bin/rspec --tag search - name: Archive logs - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: test-search-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: test-search-screenshots diff --git a/.nvmrc b/.nvmrc index f88da62e246007..12fd1fc27773ea 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.11 +24.13 diff --git a/.prettierignore b/.prettierignore index bd382506695bc3..098dac6717786f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -68,7 +68,6 @@ docker-compose.override.yml # Ignore vendored CSS reset app/javascript/styles/mastodon/reset.scss -app/javascript/styles_new/mastodon/reset.scss # Ignore Javascript pending https://github.com/mastodon/mastodon/pull/23631 *.js diff --git a/.ruby-version b/.ruby-version index 2aa51319921198..7921bd0c892723 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +3.4.8 diff --git a/.storybook/main.ts b/.storybook/main.ts index c249d1c06dcff1..2f70c80dbfe00a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -27,6 +27,7 @@ const config: StorybookConfig = { 'oops.gif', 'oops.png', ].map((path) => ({ from: `../public/${path}`, to: `/${path}` })), + { from: '../app/javascript/images/logo.svg', to: '/custom-emoji/logo.svg' }, ], viteFinal(config) { // For an unknown reason, Storybook does not use the root diff --git a/.storybook/modes.ts b/.storybook/modes.ts new file mode 100644 index 00000000000000..89675cb0bfa367 --- /dev/null +++ b/.storybook/modes.ts @@ -0,0 +1,8 @@ +export const modes = { + darkTheme: { + theme: 'dark', + }, + lightTheme: { + theme: 'light', + }, +} as const; diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index 1870d95b8fe3db..7c078c0b3b74f9 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -1,2 +1,2 @@ - + \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d66f0fb11a84d3..d2d34db80d5881 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -11,6 +11,11 @@ import type { Preview } from '@storybook/react-vite'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { action } from 'storybook/actions'; +import { + importCustomEmojiData, + importLegacyShortcodes, + importEmojiData, +} from '@/mastodon/features/emoji/loader'; import type { LocaleData } from '@/mastodon/locales'; import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; @@ -20,6 +25,7 @@ import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; // you can change the below to `/application.scss` import '../app/javascript/styles/mastodon-light.scss'; import './styles.css'; +import { modes } from './modes'; const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { query: { as: 'json' }, @@ -45,17 +51,46 @@ const preview: Preview = { dynamicTitle: true, }, }, + theme: { + description: 'Theme for the story', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: [{ value: 'light' }, { value: 'dark' }], + dynamicTitle: true, + }, + }, }, initialGlobals: { locale: 'en', + theme: 'light', }, decorators: [ - (Story, { parameters, globals, args }) => { + (Story, { parameters, globals, args, argTypes }) => { // Get the locale from the global toolbar // and merge it with any parameters or args state. const { locale } = globals as { locale: string }; const { state = {} } = parameters; - const { state: argsState = {} } = args; + + const argsState: Record = {}; + for (const [key, value] of Object.entries(args)) { + const argType = argTypes[key]; + if (argType?.reduxPath) { + const reduxPath = Array.isArray(argType.reduxPath) + ? argType.reduxPath.map((p) => p.toString()) + : argType.reduxPath.split('.'); + + reduxPath.reduce((acc, key, i) => { + if (acc[key] === undefined) { + acc[key] = {}; + } + if (i === reduxPath.length - 1) { + acc[key] = value; + } + return acc[key] as Record; + }, argsState); + } + } const reducer = reducerWithInitialState( { @@ -64,7 +99,7 @@ const preview: Preview = { }, }, state as Record, - argsState as Record, + argsState, ); const store = configureStore({ @@ -111,6 +146,13 @@ const preview: Preview = { ); }, + (Story, { globals }) => { + const theme = (globals.theme as string) || 'light'; + useEffect(() => { + document.body.setAttribute('data-color-scheme', theme); + }, [theme]); + return ; + }, (Story) => ( @@ -127,7 +169,12 @@ const preview: Preview = { ), ], - loaders: [mswLoader], + loaders: [ + mswLoader, + importCustomEmojiData, + importLegacyShortcodes, + ({ globals: { locale } }) => importEmojiData(locale as string), + ], parameters: { layout: 'centered', @@ -152,6 +199,13 @@ const preview: Preview = { msw: { handlers: mockHandlers, }, + + chromatic: { + modes: { + dark: modes.darkTheme, + light: modes.lightTheme, + }, + }, }, }; diff --git a/.storybook/storybook-addon-vitest.d.ts b/.storybook/storybook.d.ts similarity index 54% rename from .storybook/storybook-addon-vitest.d.ts rename to .storybook/storybook.d.ts index 86852faca9f8f4..47624d1e9c6783 100644 --- a/.storybook/storybook-addon-vitest.d.ts +++ b/.storybook/storybook.d.ts @@ -1,7 +1,20 @@ // The addon package.json incorrectly exports types, so we need to override them here. + +import type { RootState } from '@/mastodon/store'; + // See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76 declare module '@storybook/addon-vitest/vitest-plugin' { export * from '@storybook/addon-vitest/dist/vitest-plugin/index'; } +type RootPathKeys = keyof RootState; + +declare module 'storybook/internal/csf' { + export interface InputType { + reduxPath?: + | `${RootPathKeys}.${string}` + | [RootPathKeys, ...(string | number)[]]; + } +} + export {}; diff --git a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch b/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch deleted file mode 100644 index 0b3f94d09ee83a..00000000000000 --- a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/lib/index.js b/lib/index.js -index 16ed6be8be8f555cc99096c2ff60954b42dc313d..d009c069770d066ad0db7ad02de1ea473a29334e 100644 ---- a/lib/index.js -+++ b/lib/index.js -@@ -99,7 +99,7 @@ function lodash(_ref) { - - var node = _ref3; - -- if ((0, _types.isModuleDeclaration)(node)) { -+ if ((0, _types.isImportDeclaration)(node) || (0, _types.isExportDeclaration)(node)) { - isModule = true; - break; - } diff --git a/AUTHORS.md b/AUTHORS.md index 78cc37a17b9350..5d243ed43b1b51 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -538,7 +538,7 @@ and provided thanks to the work of the following contributors: * [Drew Schuster](mailto:dtschust@gmail.com) * [Dryusdan](mailto:dryusdan@dryusdan.fr) * [Eai](mailto:eai@mizle.net) -* [Eashwar Ranganathan](mailto:eranganathan@lyft.com) +* [Eashwar Ranganathan](mailto:eashwar@eashwar.com) * [Ed Knutson](mailto:knutsoned@gmail.com) * [Elizabeth Martín Campos](mailto:me@elizabeth.sh) * [Elizabeth Myers](mailto:elizabeth@interlinked.me) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45bb26b5140b8b..cfbc450d74a19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,97 @@ All notable changes to this project will be documented in this file. +## [4.5.6] - 2026-02-03 + +### Security + +- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr)) + +### Changed + +- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire) + +### Fixed + +- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire) +- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS) +- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire) +- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire) +- Fix cross-server conversation tracking (#37559 by @ClearlyClaire) +- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable) + +## [4.5.5] - 2026-01-20 + +### Security + +- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g) +- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp) +- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3) +- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4) + +### Changed + +- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire) + +### Fixed + +- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire) +- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire) +- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire) +- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable) +- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec) +- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec) +- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec) +- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros) +- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable) +- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima) +- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion) + +## [4.5.4] - 2026-01-07 + +### Security + +- Fix SSRF protection bypass ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-xfrj-c749-jxxq)) +- Fix missing ownership check in severed relationships controller ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-ww85-x9cp-5v24)) + +### Changed + +- Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221 by @ClearlyClaire) + +### Fixed + +- Fix custom emojis not being rendered in profile fields (#37365 by @ClearlyClaire) +- Fix serialization of context pages (#37376 by @ClearlyClaire) +- Fix quotes with CWs but no text not having fallback link (#37361 by @ClearlyClaire) +- Fix outdated link target for “locked” warning (#37366 by @ClearlyClaire) +- Fix local custom emojis sometimes being rendered in remote posts (#37284 by @ChaosExAnima) +- Fix some assets not being loaded from configured CDN (#37310 by @ChaosExAnima) +- Fix notifications page error in Tor browser (#37285 by @diondiondion) +- Fix custom emojis not being displayed in CWs and fav/boost notifications (#37272 and #37306 by @ChaosExAnima and @ClearlyClaire) +- Fix default `Admin` role not including `view_feeds` permission (#37301 by @ClearlyClaire) +- Fix hashtag autocomplete replacing suggestion's first characters with input (#37281 by @ClearlyClaire) +- Fix mentions of domain-blocked users being processed (#37257 by @ClearlyClaire) + +## [4.5.3] - 2025-12-08 + +### Security + +- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8)) + +### Fixed + +- Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140 by @ClearlyClaire) +- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima) +- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire) +- Fix creation of duplicate conversations (#37108 by @oneiros) +- Fix extraneous `noreferrer` in external links (#37107 by @ChaosExAnima) +- Fix edge case error handling in some database migrations (#37079 by @ClearlyClaire) +- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire) +- Fix post navigation in single-column mode when Advanced UI is enabled (#37044 by @diondiondion) +- Fix `tootctl status remove` removing quoted posts and remote quotes of local posts (#37009 by @ClearlyClaire) +- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire) +- Fix compose autosuggest always lowercasing input token (#36995 by @ClearlyClaire) + ## [4.5.2] - 2025-11-20 ### Changed diff --git a/Dockerfile b/Dockerfile index c64d529918d0b0..c06bc84a3395dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io" # 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.4.7" +ARG RUBY_VERSION="3.4.8" # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="24" @@ -70,8 +70,6 @@ ENV \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ # 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 \ # 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 @@ -183,7 +181,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.17.3 +ARG VIPS_VERSION=8.18.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 diff --git a/FEDERATION.md b/FEDERATION.md index 03ea5449de340b..0ac44afc3cd37c 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -48,3 +48,23 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques ### Additional documentation - [Mastodon documentation](https://docs.joinmastodon.org/) + +## Size limits + +Mastodon imposes a few hard limits on federated content. +These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accommodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons. +The following table summarizes those limits. + +| Limited property | Size limit | Consequence of exceeding the limit | +| ------------------------------------------------------------- | ---------- | ---------------------------------- | +| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** | +| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated | +| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated | +| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated | +| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** | +| Account display name (actor `name`) length | 2048 | Display name will be truncated | +| Account note (actor `summary`) length | 20kB | Account note will be truncated | +| Account `attributionDomains` | 256 | List will be truncated | +| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | +| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | +| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated | diff --git a/Gemfile b/Gemfile index 316785aff4f3b8..e3771c877c8ee3 100644 --- a/Gemfile +++ b/Gemfile @@ -24,11 +24,11 @@ gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.19.0', require: false +gem 'bootsnap', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' -gem 'devise', '~> 4.9' +gem 'devise' gem 'devise-two-factor' group :pam_authentication, optional: true do @@ -40,7 +40,7 @@ gem 'net-ldap', '~> 0.18' gem 'omniauth', '~> 2.0' gem 'omniauth-cas', '~> 3.0.0.beta.1' gem 'omniauth_openid_connect', '~> 0.8.0' -gem 'omniauth-rails_csrf_protection', '~> 1.0' +gem 'omniauth-rails_csrf_protection', '~> 2.0' gem 'omniauth-saml', '~> 2.0' gem 'color_diff', '~> 0.1' @@ -55,7 +55,7 @@ gem 'hiredis-client' gem 'htmlentities', '~> 4.3' gem 'http', '~> 5.3.0' gem 'http_accept_language', '~> 2.1' -gem 'httplog', '~> 1.7.0', require: false +gem 'httplog', '~> 1.8.0', require: false gem 'i18n' gem 'idn-ruby', require: 'idn' gem 'inline_svg' @@ -71,7 +71,7 @@ gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' gem 'premailer-rails' -gem 'public_suffix', '~> 6.0' +gem 'public_suffix', '~> 7.0' gem 'pundit', '~> 2.3' gem 'rack-attack', '~> 6.6' gem 'rack-cors', require: 'rack/cors' @@ -109,12 +109,12 @@ group :opentelemetry do gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false - gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-pg', '~> 0.33.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false @@ -139,7 +139,7 @@ group :test do gem 'capybara', '~> 3.39' gem 'capybara-playwright-driver' gem 'capybara-screenshot-diff' - gem 'playwright-ruby-client', '1.56.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package + gem 'playwright-ruby-client', '1.57.1', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package # Used to reset the database between system tests gem 'database_cleaner-active_record' @@ -188,7 +188,7 @@ group :development do gem 'letter_opener_web', '~> 3.0' # Security analysis CLI tools - gem 'brakeman', '~> 7.0', require: false + gem 'brakeman', '~> 8.0', require: false gem 'bundler-audit', '~> 0.9', require: false # Linter CLI for HAML files diff --git a/Gemfile.lock b/Gemfile.lock index 18a4d35dd1bea8..c92e3d6d5a3997 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -53,7 +53,7 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_model_serializers (0.10.15) + active_model_serializers (0.10.16) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) @@ -86,18 +86,18 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotaterb (4.20.0) + annotaterb (4.21.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1186.0) - aws-sdk-core (3.239.1) + aws-partitions (1.1213.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -105,11 +105,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.117.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (1.121.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.205.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-ssm (1.186.0) @@ -121,7 +121,7 @@ GEM rexml base64 (0.3.0) bcp47_spec (0.2.1) - bcrypt (3.1.20) + bcrypt (3.1.21) benchmark (0.5.0) better_errors (2.10.1) erubi (>= 1.0.0) @@ -132,14 +132,14 @@ GEM binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.19.0) + bootsnap (1.22.0) msgpack (~> 1.2) - brakeman (7.1.1) + brakeman (8.0.2) racc browser (6.2.0) builder (3.3.0) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) capybara (3.40.0) addressable @@ -171,9 +171,9 @@ GEM chunky_png (1.4.0) climate_control (1.2.0) cocoon (1.2.15) - color_diff (0.1) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + color_diff (0.2) + concurrent-ruby (1.3.6) + connection_pool (2.5.5) cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) @@ -188,21 +188,21 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) - date (3.5.0) - debug (1.11.0) + date (3.5.1) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) - devise (4.9.4) + devise (5.0.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0) + railties (>= 7.0) responders warden (~> 1.2.3) - devise-two-factor (6.2.0) - activesupport (>= 7.0, < 8.2) - devise (~> 4.0) - railties (>= 7.0, < 8.2) + devise-two-factor (6.4.0) + activesupport (>= 7.2, < 8.2) + devise (>= 4.0, < 6.0) + railties (>= 7.2, < 8.2) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) @@ -214,9 +214,9 @@ GEM domain_name (0.6.20240107) doorkeeper (5.8.2) railties (>= 5) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) - dry-cli (1.3.0) + dry-cli (1.4.1) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) elasticsearch-transport (= 7.17.11) @@ -233,28 +233,28 @@ GEM mail (~> 2.7) email_validator (2.2.4) activemodel - erb (5.1.3) + erb (6.0.1) erubi (1.13.1) et-orbi (1.4.0) tzinfo - excon (1.3.0) + excon (1.3.2) logger fabrication (3.0.0) - faker (3.5.2) + faker (3.6.0) i18n (>= 1.8.11, < 2) - faraday (2.14.0) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.4.0) + faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) faraday-httpclient (2.0.2) httpclient (>= 2.2) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) fast_blank (1.0.1) fastimage (2.4.0) - ffi (1.17.2) + ffi (1.17.3) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake @@ -275,20 +275,20 @@ GEM fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) - formatador (1.2.1) + formatador (1.2.3) reline - forwardable (1.3.3) - fugit (1.12.0) + forwardable (1.4.0) + fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.32.1) + google-protobuf (4.33.5) bigdecimal rake (>= 13) googleapis-common-protos-types (1.22.0) google-protobuf (~> 4.26) - haml (6.4.0) + haml (7.2.0) temple (>= 0.8.2) thor tilt @@ -297,21 +297,22 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.67.0) + haml_lint (0.69.0) haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) hashdiff (1.2.1) - hashie (5.0.0) + hashie (5.1.0) + logger hcaptcha (7.1.0) json highline (3.1.2) reline hiredis (0.6.3) - hiredis-client (0.26.1) - redis-client (= 0.26.1) + hiredis-client (0.26.4) + redis-client (= 0.26.4) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -325,12 +326,13 @@ GEM http_accept_language (2.1.1) httpclient (2.9.0) mutex_m - httplog (1.7.3) + httplog (1.8.0) + benchmark rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) - i18n-tasks (1.1.1) + i18n-tasks (1.1.2) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -346,8 +348,8 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.1) - irb (1.15.3) + io-console (0.8.2) + irb (1.16.0) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -356,7 +358,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.16.0) + json (2.18.1) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -376,9 +378,9 @@ GEM json-ld-preloaded (3.3.2) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (6.0.0) + json-schema (6.1.0) addressable (~> 2.8) - bigdecimal (~> 3.1) + bigdecimal (>= 3.1, < 5) jsonapi-renderer (0.2.2) jwt (2.10.2) base64 @@ -394,7 +396,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - kt-paperclip (7.2.2) + kt-paperclip (7.3.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) marcel (~> 1.0.1) @@ -433,7 +435,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -450,16 +452,17 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0924) + mime-types-data (3.2026.0203) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.2) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.8.0) - multi_json (1.17.0) + multi_json (1.19.1) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.12) + net-imap (0.6.2) date net-protocol net-ldap (0.20.0) @@ -471,11 +474,11 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.18.10) + nio4r (2.7.5) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.12) + oj (3.16.15) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.4) @@ -487,7 +490,7 @@ GEM addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) - omniauth-rails_csrf_protection (1.0.2) + omniauth-rails_csrf_protection (2.0.1) actionpack (>= 4.2) omniauth (~> 2.0) omniauth-saml (2.2.4) @@ -524,13 +527,14 @@ GEM opentelemetry-semantic_conventions opentelemetry-helpers-sql (0.3.0) opentelemetry-api (~> 1.7) - opentelemetry-helpers-sql-obfuscation (0.5.0) + opentelemetry-helpers-sql-processor (0.4.0) + opentelemetry-api (~> 1.0) opentelemetry-common (~> 0.21) opentelemetry-instrumentation-action_mailer (0.6.1) opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-action_pack (0.15.1) opentelemetry-instrumentation-rack (~> 0.29) - opentelemetry-instrumentation-action_view (0.11.1) + opentelemetry-instrumentation-action_view (0.11.2) opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-active_job (0.10.1) opentelemetry-instrumentation-base (~> 0.25) @@ -548,19 +552,19 @@ GEM opentelemetry-registry (~> 0.1) opentelemetry-instrumentation-concurrent_ruby (0.24.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-excon (0.26.0) + opentelemetry-instrumentation-excon (0.27.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-faraday (0.30.0) + opentelemetry-instrumentation-faraday (0.31.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http (0.27.0) + opentelemetry-instrumentation-http (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http_client (0.26.0) + opentelemetry-instrumentation-http_client (0.27.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-net_http (0.26.0) + opentelemetry-instrumentation-net_http (0.27.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-pg (0.33.0) + opentelemetry-instrumentation-pg (0.35.0) opentelemetry-helpers-sql - opentelemetry-helpers-sql-obfuscation + opentelemetry-helpers-sql-processor opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-rack (0.29.0) opentelemetry-instrumentation-base (~> 0.25) @@ -575,7 +579,7 @@ GEM opentelemetry-instrumentation-concurrent_ruby (~> 0.23) opentelemetry-instrumentation-redis (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-sidekiq (0.28.0) + opentelemetry-instrumentation-sidekiq (0.28.1) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) @@ -591,16 +595,17 @@ GEM ox (2.14.23) bigdecimal (>= 3.0) parallel (1.27.0) - parser (3.3.10.0) + parser (3.3.10.1) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.6.2) + pg (1.6.3) pghero (3.7.0) activerecord (>= 7.1) - playwright-ruby-client (1.56.0) + playwright-ruby-client (1.57.1) + base64 concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) pp (0.6.3) @@ -614,18 +619,18 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.6.0) + prism (1.9.0) prometheus_exporter (2.3.1) webrick propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (7.1.0) + public_suffix (7.0.2) + puma (7.2.0) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) @@ -637,14 +642,14 @@ GEM rack-cors (3.0.0) logger rack (>= 3.0.14) - rack-oauth2 (2.2.1) + rack-oauth2 (2.3.0) activesupport attr_required faraday (~> 2.0) faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) - rack-protection (4.1.1) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -655,7 +660,7 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) rails (8.0.3) actioncable (= 8.0.3) @@ -678,7 +683,7 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) 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.2) + rails-i18n (8.1.0) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) railties (8.0.3) @@ -701,7 +706,7 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.15.1) + rdoc (7.1.0) erb psych (>= 4.0.0) tsort @@ -709,7 +714,7 @@ GEM reline redcarpet (3.6.1) redis (4.8.1) - redis-client (0.26.1) + redis-client (0.26.4) connection_pool regexp_parser (2.11.3) reline (0.6.3) @@ -721,24 +726,24 @@ GEM railties (>= 7.0) rexml (3.4.4) rotp (6.3.0) - rouge (4.6.1) + rouge (4.7.0) rpam2 (4.0.2) - rqrcode (3.1.0) + rqrcode (3.2.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.0) - rspec (3.13.1) + rqrcode_core (2.1.0) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (3.0.0) rspec-core (~> 3.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (8.0.2) @@ -754,8 +759,8 @@ GEM rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) - rspec-support (3.13.6) - rubocop (1.81.7) + rspec-support (3.13.7) + rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -763,12 +768,12 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.48.0) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) + prism (~> 1.7) rubocop-capybara (2.22.1) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) @@ -779,13 +784,13 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.33.4) + rubocop-rails (2.34.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rspec (3.8.0) + rubocop-rspec (3.9.0) lint_roller (~> 1.1) rubocop (~> 1.81) rubocop-rspec_rails (2.32.0) @@ -798,7 +803,7 @@ GEM ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.5) + ruby-vips (2.3.0) ffi (~> 1.12) logger rubyzip (3.2.2) @@ -815,7 +820,7 @@ GEM securerandom (0.4.1) shoulda-matchers (7.0.1) activesupport (>= 7.1) - sidekiq (8.0.9) + sidekiq (8.0.10) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) @@ -826,13 +831,14 @@ GEM sidekiq-scheduler (6.0.1) rufus-scheduler (~> 3.2) sidekiq (>= 7.3, < 9) - sidekiq-unique-jobs (8.0.11) + sidekiq-unique-jobs (8.0.13) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 7.0.0, < 9.0.0) thor (>= 1.0, < 3.0) - simple-navigation (4.4.0) + simple-navigation (4.4.1) activesupport (>= 2.3.2) - simple_form (5.4.0) + ostruct + simple_form (5.4.1) actionpack (>= 7.0) activemodel (>= 7.0) simplecov (0.22.0) @@ -845,10 +851,11 @@ GEM stackprof (0.2.27) starry (0.2.0) base64 - stoplight (5.6.0) + stoplight (5.7.0) + concurrent-ruby zeitwerk - stringio (3.1.8) - strong_migrations (2.5.1) + stringio (3.2.0) + strong_migrations (2.5.2) activerecord (>= 7.1) swd (2.0.3) activesupport (>= 3) @@ -861,10 +868,10 @@ GEM unicode-display_width (>= 1.1.1, < 4) terrapin (1.1.1) climate_control - test-prof (1.4.4) - thor (1.4.0) - tilt (2.6.1) - timeout (0.4.3) + test-prof (1.5.2) + thor (1.5.0) + tilt (2.7.0) + timeout (0.6.0) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) @@ -885,20 +892,20 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.2) + tzinfo-data (1.2025.3) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext unf_ext (0.0.9.1) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) + unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix - vite_rails (3.0.19) + vite_rails (3.0.20) railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (3.9.2) @@ -925,7 +932,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) + webrick (1.9.2) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -934,7 +941,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.3) + zeitwerk (2.7.4) PLATFORMS ruby @@ -949,8 +956,8 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.19.0) - brakeman (~> 7.0) + bootsnap + brakeman (~> 8.0) browser bundler-audit (~> 0.9) capybara (~> 3.39) @@ -966,7 +973,7 @@ DEPENDENCIES csv (~> 3.2) database_cleaner-active_record debug (~> 1.8) - devise (~> 4.9) + devise devise-two-factor devise_pam_authenticatable2 (~> 9.2) discard (~> 1.2) @@ -989,7 +996,7 @@ DEPENDENCIES htmlentities (~> 4.3) http (~> 5.3.0) http_accept_language (~> 2.1) - httplog (~> 1.7.0) + httplog (~> 1.8.0) i18n i18n-tasks (~> 1.0) idn-ruby @@ -1017,7 +1024,7 @@ DEPENDENCIES oj (~> 3.14) omniauth (~> 2.0) omniauth-cas (~> 3.0.0.beta.1) - omniauth-rails_csrf_protection (~> 1.0) + omniauth-rails_csrf_protection (~> 2.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.8.0) opentelemetry-api (~> 1.7.0) @@ -1025,12 +1032,12 @@ DEPENDENCIES opentelemetry-instrumentation-active_job (~> 0.10.0) opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) - opentelemetry-instrumentation-excon (~> 0.26.0) - opentelemetry-instrumentation-faraday (~> 0.30.0) - opentelemetry-instrumentation-http (~> 0.27.0) - opentelemetry-instrumentation-http_client (~> 0.26.0) - opentelemetry-instrumentation-net_http (~> 0.26.0) - opentelemetry-instrumentation-pg (~> 0.33.0) + opentelemetry-instrumentation-excon (~> 0.27.0) + opentelemetry-instrumentation-faraday (~> 0.31.0) + opentelemetry-instrumentation-http (~> 0.28.0) + opentelemetry-instrumentation-http_client (~> 0.27.0) + opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-pg (~> 0.35.0) opentelemetry-instrumentation-rack (~> 0.29.0) opentelemetry-instrumentation-rails (~> 0.39.0) opentelemetry-instrumentation-redis (~> 0.28.0) @@ -1040,11 +1047,11 @@ DEPENDENCIES parslet pg (~> 1.5) pghero - playwright-ruby-client (= 1.56.0) + playwright-ruby-client (= 1.57.1) premailer-rails prometheus_exporter (~> 2.2) propshaft - public_suffix (~> 6.0) + public_suffix (~> 7.0) puma (~> 7.0) pundit (~> 2.3) rack-attack (~> 6.6) @@ -1097,7 +1104,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.8 BUNDLED WITH - 2.7.2 + 4.0.6 diff --git a/SECURITY.md b/SECURITY.md index 12052652e6ca89..e5790a66fa20c8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through | 4.5.x | Yes | | 4.4.x | Yes | | 4.3.x | Until 2026-05-06 | -| 4.2.x | Until 2026-01-08 | -| < 4.2 | No | +| < 4.3 | No | diff --git a/Vagrantfile b/Vagrantfile index 0a34367024070a..a2c0b13b146031 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -29,7 +29,6 @@ sudo apt-get install \ libpq-dev \ libxml2-dev \ libxslt1-dev \ - imagemagick \ nodejs \ redis-server \ redis-tools \ diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index c80db3500ded77..5f3c1311a85994 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -1,20 +1,36 @@ # frozen_string_literal: true class ActivityPub::CollectionsController < ActivityPub::BaseController + SUPPORTED_COLLECTIONS = %w(featured tags).freeze + vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :check_authorization before_action :set_items before_action :set_size before_action :set_type def show expires_in 3.minutes, public: public_fetch_mode? - render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + + if @unauthorized + render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + else + render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end end private + def check_authorization + # Because in public fetch mode we cache the response, there would be no + # benefit from performing the check below, since a blocked account or domain + # would likely be served the cache from the reverse proxy anyway + + @unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + end + def set_items case params[:id] when 'featured' @@ -57,11 +73,7 @@ def collection_presenter end def for_signed_account - # Because in public fetch mode we cache the response, there would be no - # benefit from performing the check below, since a blocked account or domain - # would likely be served the cache from the reverse proxy anyway - - if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + if @unauthorized [] else yield diff --git a/app/controllers/activitypub/contexts_controller.rb b/app/controllers/activitypub/contexts_controller.rb index 4daa75552e22f5..efe215cd142718 100644 --- a/app/controllers/activitypub/contexts_controller.rb +++ b/app/controllers/activitypub/contexts_controller.rb @@ -36,9 +36,8 @@ def set_items def context_presenter first_page = ActivityPub::CollectionPresenter.new( - id: items_context_url(@conversation, page_params), type: :unordered, - part_of: items_context_url(@conversation), + part_of: context_url(@conversation), next: next_page, items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri } ) @@ -52,7 +51,7 @@ def items_collection_presenter page = ActivityPub::CollectionPresenter.new( id: items_context_url(@conversation, page_params), type: :unordered, - part_of: items_context_url(@conversation), + part_of: context_url(@conversation), next: next_page, items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri } ) diff --git a/app/controllers/activitypub/featured_collections_controller.rb b/app/controllers/activitypub/featured_collections_controller.rb new file mode 100644 index 00000000000000..872d03423d2172 --- /dev/null +++ b/app/controllers/activitypub/featured_collections_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class ActivityPub::FeaturedCollectionsController < ApplicationController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + PER_PAGE = 5 + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, if: -> { authorized_fetch_mode? } + before_action :set_collections + + skip_around_action :set_locale + skip_before_action :require_functional!, unless: :limited_federation_mode? + + def index + respond_to do |format| + format.json do + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) + + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_collections + authorize @account, :index_collections? + @collections = @account.collections.page(params[:page]).per(PER_PAGE) + rescue Mastodon::NotPermittedError + not_found + end + + def page_requested? + params[:page].present? + end + + def next_page_url + ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page) + end + + def prev_page_url + ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page) + end + + def collection_presenter + if page_requested? + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)), + type: :unordered, + size: @account.collections.count, + items: @collections, + part_of: ap_account_featured_collections_url(@account), + next: next_page_url, + prev: prev_page_url + ) + else + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account), + type: :unordered, + size: @account.collections.count, + first: ap_account_featured_collections_url(@account, page: 1) + ) + end + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 49cfc8ad1cbd8c..cf46bf21b5e44c 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -3,6 +3,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController include JsonLdHelper + before_action :skip_large_payload before_action :skip_unknown_actor_activity before_action :require_actor_signature! skip_before_action :authenticate_user! @@ -16,6 +17,10 @@ def create private + def skip_large_payload + head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE + end + def skip_unknown_actor_activity head 202 if unknown_affected_account? end @@ -39,7 +44,7 @@ def body return @body if defined?(@body) @body = request.body.read - @body.force_encoding('UTF-8') if @body.present? + @body.presence&.force_encoding('UTF-8') request.body.rewind if request.body.respond_to?(:rewind) diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb index e875517b021ec2..4dcddb88e4bfa1 100644 --- a/app/controllers/activitypub/likes_controller.rb +++ b/app/controllers/activitypub/likes_controller.rb @@ -22,7 +22,7 @@ def pundit_user def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb index f4a150555084d0..ff4a76df34ced2 100644 --- a/app/controllers/activitypub/quote_authorizations_controller.rb +++ b/app/controllers/activitypub/quote_authorizations_controller.rb @@ -24,7 +24,7 @@ def set_quote_authorization return not_found unless @quote.status.present? && @quote.quoted_status.present? authorize @quote.quoted_status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 1959f50d676196..a857ba03faf6c3 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -25,7 +25,7 @@ def pundit_user def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb index 2d1e389885a935..3733dfbd6f3fa6 100644 --- a/app/controllers/activitypub/shares_controller.rb +++ b/app/controllers/activitypub/shares_controller.rb @@ -22,7 +22,7 @@ def pundit_user def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index e3da834fcd3d07..b6fc9ef27c633d 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -5,6 +5,15 @@ class CustomEmojisController < BaseController def index authorize :custom_emoji, :index? + # If filtering by local emojis, remove by_domain filter. + params.delete(:by_domain) if params[:local].present? + + # If filtering by domain, ensure remote filter is set. + if params[:by_domain].present? + params.delete(:local) + params[:remote] = '1' + end + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) @form = Form::CustomEmojiBatch.new end diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb index 6d115631a2b2d8..b9b58b23d4434d 100644 --- a/app/controllers/api/v1/accounts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/notes_controller.rb @@ -9,9 +9,9 @@ class Api::V1::Accounts::NotesController < Api::BaseController def create if params[:comment].blank? - AccountNote.find_by(account: current_account, target_account: @account)&.destroy + current_account.account_notes.find_by(target_account: @account)&.destroy else - @note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account) + @note = current_account.account_notes.find_or_initialize_by(target_account: @account) @note.comment = params[:comment] @note.save! if @note.changed? end diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb index b1aee288dd8595..71a97e1d9a68b2 100644 --- a/app/controllers/api/v1/annual_reports_controller.rb +++ b/app/controllers/api/v1/annual_reports_controller.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class Api::V1::AnnualReportsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index - before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index + include AsyncRefreshesConcern + + before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, except: [:read, :generate] + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:read, :generate] before_action :require_user! - before_action :set_annual_report, except: :index + before_action :set_annual_report, only: [:show, :read] def index with_read_replica do @@ -28,6 +30,28 @@ def show relationships: @relationships end + def state + render json: { state: report_state } + end + + def generate + return render_empty unless year == AnnualReport.current_campaign + return render_empty if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year) + + async_refresh = AsyncRefresh.new(refresh_key) + + if async_refresh.running? + add_async_refresh_header(async_refresh, retry_seconds: 2) + return head 202 + end + + add_async_refresh_header(AsyncRefresh.create(refresh_key), retry_seconds: 2) + + GenerateAnnualReportWorker.perform_async(current_account.id, year) + + head 202 + end + def read @annual_report.view! render_empty @@ -35,7 +59,21 @@ def read private + def report_state + AnnualReport.new(current_account, year).state do |async_refresh| + add_async_refresh_header(async_refresh, retry_seconds: 2) + end + end + + def refresh_key + "wrapstodon:#{current_account.id}:#{year}" + end + + def year + params[:id]&.to_i + end + def set_annual_report - @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id]) + @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: year) end end diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 2833687a38cb1f..659e52bac48fba 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -17,7 +17,7 @@ def create def set_poll @poll = Poll.find(params[:poll_id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index b4c25476e8544b..bf30c178571e93 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -17,7 +17,7 @@ def show def set_poll @poll = Poll.find(params[:id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses/base_controller.rb b/app/controllers/api/v1/statuses/base_controller.rb index 3f56b68bcf41f1..0c4c49a2c3ff26 100644 --- a/app/controllers/api/v1/statuses/base_controller.rb +++ b/app/controllers/api/v1/statuses/base_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController def set_status @status = Status.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index 109b12f467efe0..b4b976ac3c5ce6 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -23,7 +23,7 @@ def destroy bookmark&.destroy! render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false }) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index dbc75a03644dbb..17eeccdbe749f0 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -25,7 +25,7 @@ def destroy relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } }) render json: @status, serializer: REST::StatusSerializer, relationships: relationships - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb index 7107890af1e0a3..32a5f71293d537 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -26,7 +26,7 @@ def destroy def distribute_add_activity! json = ActiveModelSerializers::SerializableResource.new( @status, - serializer: ActivityPub::AddSerializer, + serializer: ActivityPub::AddNoteSerializer, adapter: ActivityPub::Adapter ).as_json @@ -36,7 +36,7 @@ def distribute_add_activity! def distribute_remove_activity! json = ActiveModelSerializers::SerializableResource.new( @status, - serializer: ActivityPub::RemoveSerializer, + serializer: ActivityPub::RemoveNoteSerializer, adapter: ActivityPub::Adapter ).as_json diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb index be3a4edc83d87a..d851e55c293fef 100644 --- a/app/controllers/api/v1/statuses/quotes_controller.rb +++ b/app/controllers/api/v1/statuses/quotes_controller.rb @@ -41,8 +41,8 @@ def set_statuses if current_account&.id != @status.account_id domains = @statuses.filter_map(&:account_domain).uniq account_ids = @statuses.map(&:account_id).uniq - relations = current_account&.relations_map(account_ids, domains) || {} - @statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? } + current_account&.preload_relations!(account_ids, domains) + @statuses.reject! { |status| StatusFilter.new(status, current_account).filtered? } end end diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index 971b054c548f19..6a5788fca3015d 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -36,7 +36,7 @@ def destroy relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } }) render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end @@ -45,7 +45,7 @@ def destroy def set_reblog @reblog = Status.find(params[:status_id]) authorize @reblog, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 791fc07710b146..6d94fda8f4a50d 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -108,9 +108,7 @@ def update @status = Status.where(account: current_account).find(params[:id]) authorize @status, :update? - UpdateStatusService.new.call( - @status, - current_account.id, + update_options = { text: status_params[:status], media_ids: status_params[:media_ids], media_attributes: status_params[:media_attributes], @@ -118,8 +116,11 @@ def update language: status_params[:language], spoiler_text: status_params[:spoiler_text], poll: status_params[:poll], - quote_approval_policy: quote_approval_policy - ) + } + + update_options[:quote_approval_policy] = quote_approval_policy if status_params[:quote_approval_policy].present? + + UpdateStatusService.new.call(@status, current_account.id, update_options) render json: @status, serializer: REST::StatusSerializer end @@ -148,7 +149,7 @@ def set_statuses def set_status @status = Status.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb new file mode 100644 index 00000000000000..3f1f10a3ceb4d2 --- /dev/null +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Api::V1Alpha::CollectionItemsController < Api::BaseController + include Authorization + + before_action :check_feature_enabled + + before_action -> { doorkeeper_authorize! :write, :'write:collections' } + + before_action :require_user! + + before_action :set_collection + before_action :set_account, only: [:create] + before_action :set_collection_item, only: [:destroy] + + after_action :verify_authorized + + def create + authorize @collection, :update? + authorize @account, :feature? + + @item = AddAccountToCollectionService.new.call(@collection, @account) + + render json: @item, serializer: REST::CollectionItemSerializer, adapter: :json + end + + def destroy + authorize @collection, :update? + + @collection_item.destroy + + head 200 + end + + private + + def set_collection + @collection = Collection.find(params[:collection_id]) + end + + def set_account + return render(json: { error: '`account_id` parameter is missing' }, status: 422) if params[:account_id].blank? + + @account = Account.find(params[:account_id]) + end + + def set_collection_item + @collection_item = @collection.collection_items.find(params[:id]) + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb new file mode 100644 index 00000000000000..feea6c6b32e639 --- /dev/null +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class Api::V1Alpha::CollectionsController < Api::BaseController + include Authorization + + DEFAULT_COLLECTIONS_LIMIT = 40 + + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422 + end + + before_action :check_feature_enabled + + before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy] + + before_action :require_user!, only: [:create, :update, :destroy] + + before_action :set_account, only: [:index] + before_action :set_collections, only: [:index] + before_action :set_collection, only: [:show, :update, :destroy] + + after_action :insert_pagination_headers, only: [:index] + + after_action :verify_authorized + + def index + cache_if_unauthenticated! + authorize @account, :index_collections? + + render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json + rescue Mastodon::NotPermittedError + render json: { collections: [] } + end + + def show + cache_if_unauthenticated! + authorize @collection, :show? + + render json: @collection, serializer: REST::CollectionWithAccountsSerializer + end + + def create + authorize Collection, :create? + + @collection = CreateCollectionService.new.call(collection_creation_params, current_user.account) + + render json: @collection, serializer: REST::CollectionSerializer, adapter: :json + end + + def update + authorize @collection, :update? + + UpdateCollectionService.new.call(@collection, collection_update_params) + + render json: @collection, serializer: REST::CollectionSerializer, adapter: :json + end + + def destroy + authorize @collection, :destroy? + + DeleteCollectionService.new.call(@collection) + + head 200 + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def set_collections + @collections = @account.collections + .with_tag + .order(created_at: :desc) + .offset(offset_param) + .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections = @collections.discoverable unless @account == current_account + end + + def set_collection + @collection = Collection.find(params[:id]) + end + + def collection_creation_params + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name, account_ids: []) + end + + def collection_update_params + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name) + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end + + def next_path + return unless records_continue? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def prev_path + return if offset_param.zero? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def records_continue? + ((offset_param * limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections.size) < @account.collections.size + end + + def offset_param + params[:offset].to_i + end +end diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index f82c1c50d79502..fba56b405864ef 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -30,7 +30,7 @@ def show def set_status @status = Status.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index ced68d39fc7504..2edd92dbc7be85 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -62,7 +62,7 @@ def update_session_with_subscription end def set_push_subscription - @push_subscription = ::Web::PushSubscription.find(params[:id]) + @push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id]) end def subscription_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 82d9e8380fc854..efee54cb7bb50a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -18,6 +18,8 @@ class ApplicationController < ActionController::Base helper_method :current_account helper_method :current_session helper_method :current_theme + helper_method :color_scheme + helper_method :contrast helper_method :single_user_mode? helper_method :use_seamless_external_login? helper_method :sso_account_settings @@ -177,6 +179,25 @@ def current_theme current_user.setting_theme end + def color_scheme + current = current_user&.setting_color_scheme + return current if current && current != 'auto' + + return 'dark' if current_theme.include?('default') || current_theme.include?('contrast') + return 'light' if current_theme.include?('light') + + 'auto' + end + + def contrast + current = current_user&.setting_contrast + return current if current && current != 'auto' + + return 'high' if current_theme.include?('contrast') + + 'auto' + end + def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 780c0be3191cbe..b315b273d58b09 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -130,12 +130,17 @@ def set_rules end def require_rules_acceptance! - return if @rules.empty? || (session[:accept_token].present? && params[:accept] == session[:accept_token]) + return if @rules.empty? || validated_accept_token? @accept_token = session[:accept_token] = SecureRandom.hex - @invite_code = invite_code + @invite_code = invite_code + @rule_translations = @rules.map { |rule| rule.translation_for(I18n.locale) } - set_locale { render :rules } + render :rules + end + + def validated_accept_token? + session[:accept_token].present? && params[:accept] == session[:accept_token] end def is_flashing_format? # rubocop:disable Naming/PredicatePrefix diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 182f242ae5b521..077f4d9db5c0ae 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -197,14 +197,14 @@ def second_factor_attempts_key(user) "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" end - def respond_to_on_destroy + 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 } + format.all { super(**) } end end end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index 99eed018b070ed..03cad3e3175f03 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -21,7 +21,7 @@ def show def set_resource @resource = located_resource authorize(@resource, :show?) if @resource.is_a?(Status) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb new file mode 100644 index 00000000000000..3e2ba714702d59 --- /dev/null +++ b/app/controllers/collections_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class CollectionsController < ApplicationController + include WebAppControllerConcern + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :set_collection + + skip_around_action :set_locale, if: -> { request.format == :json } + skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? + + def show + respond_to do |format| + # TODO: format.html + + format.json do + expires_in expiration_duration, public: true if public_fetch_mode? + render_with_cache json: @collection, content_type: 'application/activity+json', serializer: ActivityPub::FeaturedCollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def set_collection + @collection = @account.collections.find(params[:id]) + authorize @collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end + + def expiration_duration + recently_updated = @collection.updated_at > 15.minutes.ago + recently_updated ? 30.seconds : 5.minutes + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb index c1349915f84dab..9c16d573c57b51 100644 --- a/app/controllers/concerns/accountable_concern.rb +++ b/app/controllers/concerns/accountable_concern.rb @@ -4,10 +4,8 @@ module AccountableConcern extend ActiveSupport::Concern def log_action(action, target) - Admin::ActionLog.create( - account: current_account, - action: action, - target: target - ) + current_account + .action_logs + .create(action:, target:) end end diff --git a/app/controllers/concerns/api/interaction_policies_concern.rb b/app/controllers/concerns/api/interaction_policies_concern.rb index f1e1480c0c0cb9..0679c3c691e41f 100644 --- a/app/controllers/concerns/api/interaction_policies_concern.rb +++ b/app/controllers/concerns/api/interaction_policies_concern.rb @@ -6,9 +6,9 @@ module Api::InteractionPoliciesConcern def quote_approval_policy case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy when 'public' - Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16 + InteractionPolicy::POLICY_FLAGS[:public] << 16 when 'followers' - Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16 + InteractionPolicy::POLICY_FLAGS[:followers] << 16 when 'nobody' 0 else diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index b1b09f2aab0342..3527cdaca0352a 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -19,7 +19,7 @@ def vary_by(value, **kwargs) # from being used as cache keys, while allowing to `Vary` on them (to not serve # anonymous cached data to authenticated requests when authentication matters) def enforce_cache_control! - vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase } + vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?) return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? } response.cache_control.replace(private: true, no_store: true) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 2bdd3558643526..1e83ab9c69b6b7 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -72,10 +72,13 @@ def signed_request_actor rescue Mastodon::SignatureVerificationError => e fail_with! e.message rescue *Mastodon::HTTP_CONNECTION_ERRORS => e + @signature_verification_failure_code ||= 503 fail_with! "Failed to fetch remote data: #{e.message}" rescue Mastodon::UnexpectedResponseError + @signature_verification_failure_code ||= 503 fail_with! 'Failed to fetch remote data (got unexpected reply from server)' rescue Stoplight::Error::RedLight + @signature_verification_failure_code ||= 503 fail_with! 'Fetching attempt skipped because of recent connection failure' end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 9d10468e69330e..0590ea40270cd3 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -34,7 +34,7 @@ def set_media_attachment def verify_permitted_status! authorize @media_attachment.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index d55b90ad88761c..267107b6272c1e 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -11,9 +11,7 @@ class MediaProxyController < ApplicationController before_action :authenticate_user!, if: :limited_federation_mode? before_action :set_media_attachment - rescue_from ActiveRecord::RecordInvalid, with: :not_found - rescue_from Mastodon::UnexpectedResponseError, with: :not_found - rescue_from Mastodon::NotPermittedError, with: :not_found + rescue_from ActiveRecord::RecordInvalid, Mastodon::NotPermittedError, Mastodon::UnexpectedResponseError, with: :not_found rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) def show diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bf7edbfdaf3cdb..75f0b42e8327a3 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController - skip_before_action :authenticate_resource_owner! - - before_action :store_current_location - before_action :authenticate_resource_owner! + prepend_before_action :store_current_location layout 'modal' @@ -20,14 +17,8 @@ def store_current_location store_location_for(:user, request.url) end - def render_success - if skip_authorization? || (matching_token? && !truthy_param?('force_login')) - redirect_or_render authorize_response - elsif Doorkeeper.configuration.api_only - render json: pre_auth - else - render :new - end + def can_authorize_response? + !truthy_param?('force_login') && super end def truthy_param?(key) diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb index 817abebf62d1da..9371ebf7d0676a 100644 --- a/app/controllers/severed_relationships_controller.rb +++ b/app/controllers/severed_relationships_controller.rb @@ -26,7 +26,7 @@ def followers private def set_event - @event = AccountRelationshipSeveranceEvent.find(params[:id]) + @event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id]) end def following_data diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index af6bebf36fd753..be3641589fcd4b 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -29,7 +29,7 @@ def show end format.json do - expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? + expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode? render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter end end @@ -37,7 +37,7 @@ def show def activity expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? - render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + render_with_cache json: @status, content_type: 'application/activity+json', serializer: activity_serializer, adapter: ActivityPub::Adapter end def embed @@ -62,11 +62,15 @@ def set_link_headers def set_status @status = @account.statuses.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end def redirect_to_original redirect_to(ActivityPub::TagManager.instance.url_for(@status.reblog), allow_other_host: true) if @status.reblog? end + + def activity_serializer + @status.reblog? ? ActivityPub::AnnounceNoteSerializer : ActivityPub::CreateNoteSerializer + end end diff --git a/app/controllers/wrapstodon_controller.rb b/app/controllers/wrapstodon_controller.rb new file mode 100644 index 00000000000000..b1fe521fb114d5 --- /dev/null +++ b/app/controllers/wrapstodon_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class WrapstodonController < ApplicationController + include WebAppControllerConcern + include Authorization + include AccountOwnedConcern + + vary_by 'Accept, Accept-Language, Cookie' + + before_action :set_generated_annual_report + + skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? + + def show + expires_in 10.minutes, public: true if current_account.nil? + end + + private + + def set_generated_annual_report + @generated_annual_report = GeneratedAnnualReport.find_by!(account: @account, year: params[:year], share_key: params[:share_key]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d80c050b853a7f..b23968e3731ed7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -89,6 +89,12 @@ def title Rails.env.production? ? site_title : "#{site_title} (Dev)" end + def page_color_scheme + return content_for(:force_color_scheme) if content_for(:force_color_scheme) + + color_scheme + end + def label_for_scope(scope) safe_join [ tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), @@ -153,10 +159,22 @@ def opengraph(property, content) tag.meta(content: content, property: property) end - def body_classes + def html_attributes + base = { + lang: I18n.locale, + class: html_classes, + 'data-contrast': contrast.parameterize, + 'data-color-scheme': page_color_scheme.parameterize, + } + + base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto' + + base + end + + def html_classes output = [] - output << content_for(:body_classes) - output << "theme-#{current_theme.parameterize}" + output << content_for(:html_classes) 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') @@ -164,6 +182,12 @@ def body_classes output.compact_blank.join(' ') end + def body_classes + output = [] + output << content_for(:body_classes) + output.compact_blank.join(' ') + end + def cdn_host Rails.configuration.action_controller.asset_host end diff --git a/app/helpers/database_helper.rb b/app/helpers/database_helper.rb index 62a26a0c2a05c8..f245d303d10ba9 100644 --- a/app/helpers/database_helper.rb +++ b/app/helpers/database_helper.rb @@ -2,7 +2,7 @@ module DatabaseHelper def replica_enabled? - ENV['REPLICA_DB_NAME'] || ENV.fetch('REPLICA_DATABASE_URL', nil) + ENV['REPLICA_DB_NAME'] || ENV['REPLICA_DB_HOST'] || ENV.fetch('REPLICA_DATABASE_URL', nil) end module_function :replica_enabled? diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 22a1c172de2c86..ec0d55788327d7 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module FiltersHelper + KEYWORDS_LIMIT = 5 + def filter_action_label(action) safe_join( [ @@ -9,4 +11,10 @@ def filter_action_label(action) ] ) end + + def filter_keywords(filter) + filter.keywords.map(&:keyword).take(KEYWORDS_LIMIT).tap do |list| + list << '…' if filter.keywords.size > KEYWORDS_LIMIT + end.join(', ') + end end diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 675d8b87309455..804cc72d706587 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -226,6 +226,72 @@ def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_error end end + # Iterate through the pages of an activitypub collection, + # returning the collected items and the number of pages that were fetched. + # + # @param collection_or_uri [String, Hash] + # either the URI or an already-fetched AP object + # @param max_pages [Integer, nil] + # Max pages to fetch, if nil, fetch until no more pages + # @param max_items [Integer, nil] + # Max items to fetch, if nil, fetch until no more items + # @param reference_uri [String, nil] + # If not nil, a URI to compare to the collection URI. + # If the host of the collection URI does not match the reference URI, + # do not fetch the collection page. + # @param on_behalf_of [Account, nil] + # Sign the request on behalf of the Account, if not nil + # @return [Array, Integer>, nil] + # The collection items and the number of pages fetched + def collection_items(collection_or_uri, max_pages: 1, max_items: nil, reference_uri: nil, on_behalf_of: nil) + collection = fetch_collection_page(collection_or_uri, reference_uri: reference_uri, on_behalf_of: on_behalf_of) + return unless collection.is_a?(Hash) + + collection = fetch_collection_page(collection['first'], reference_uri: reference_uri, on_behalf_of: on_behalf_of) if collection['first'].present? + return unless collection.is_a?(Hash) + + items = [] + n_pages = 1 + while collection.is_a?(Hash) + items.concat(as_array(collection_page_items(collection))) + + break if !max_items.nil? && items.size >= max_items + break if !max_pages.nil? && n_pages >= max_pages + + collection = collection['next'].present? ? fetch_collection_page(collection['next'], reference_uri: reference_uri, on_behalf_of: on_behalf_of) : nil + n_pages += 1 + end + + [items, n_pages] + end + + def collection_page_items(collection) + case collection['type'] + when 'Collection', 'CollectionPage' + collection['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + collection['orderedItems'] + end + end + + # Fetch a single collection page + # To get the whole collection, use collection_items + # + # @param collection_or_uri [String, Hash] + # @param reference_uri [String, nil] + # If not nil, a URI to compare to the collection URI. + # If the host of the collection URI does not match the reference URI, + # do not fetch the collection page. + # @param on_behalf_of [Account, nil] + # Sign the request on behalf of the Account, if not nil + # @return [Hash, nil] + def fetch_collection_page(collection_or_uri, reference_uri: nil, on_behalf_of: nil) + return collection_or_uri if collection_or_uri.is_a?(Hash) + return if !reference_uri.nil? && non_matching_uri_hosts?(reference_uri, collection_or_uri) + + fetch_resource_without_id_validation(collection_or_uri, on_behalf_of, raise_on_error: :temporary) + end + def valid_activitypub_content_type?(response) return true if response.mime_type == 'application/activity+json' diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index e9a48d8e64dfbd..a8ad10e91bf032 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -234,6 +234,7 @@ module LanguagesHelper 'es-MX': 'Español (México)', 'fr-CA': 'Français (Canadien)', 'ja-IM': '日本語(im@s)', + 'nan-TW': '臺語 (Hô-ló話)', 'pt-BR': 'Português (Brasil)', 'pt-PT': 'Português (Portugal)', 'sr-Latn': 'Srpski (latinica)', diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index 32734b93af9fac..f651a495ffee8b 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -1,25 +1,40 @@ # frozen_string_literal: true module ThemeHelper - def theme_style_tags(theme) - if theme == 'system' - ''.html_safe.tap do |tags| - tags << vite_stylesheet_tag(theme_path_for('mastodon-light'), type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') - tags << vite_stylesheet_tag(theme_path_for('default'), type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + def javascript_inline_tag(path) + entry = InlineScriptManager.instance.file(path) + + # Only add hash if we don't allow arbitrary includes already, otherwise it's going + # to break the React Tools browser extension or other inline scripts + unless Rails.env.development? && request.content_security_policy.dup.script_src.include?("'unsafe-inline'") + request.content_security_policy = request.content_security_policy.clone.tap do |policy| + values = policy.script_src + values << "'sha256-#{entry[:digest]}'" + policy.script_src(*values) end - else - vite_stylesheet_tag theme_path_for(theme), type: :virtual, media: 'all', crossorigin: 'anonymous' end + + content_tag(:script, entry[:contents], type: 'text/javascript') + end + + def theme_style_tags(theme) + # TODO: get rid of that when we retire the themes and perform the settings migration + theme = 'default' if %w(mastodon-light contrast system).include?(theme) + + vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' end - def theme_color_tags(theme) - if theme == 'system' + def theme_color_tags(color_scheme) + case color_scheme + when 'auto' ''.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) + when 'light' + tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:light] + when 'dark' + tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:dark] end end @@ -49,12 +64,4 @@ def cached_custom_css_digest 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 - - def theme_path_for(theme) - Mastodon::Feature.theme_tokens_enabled? ? "themes/#{theme}_theme_tokens" : "themes/#{theme}" - end end diff --git a/app/helpers/wrapstodon_helper.rb b/app/helpers/wrapstodon_helper.rb new file mode 100644 index 00000000000000..5a0075a0e58be8 --- /dev/null +++ b/app/helpers/wrapstodon_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module WrapstodonHelper + def render_wrapstodon_share_data(report) + payload = ActiveModelSerializers::SerializableResource.new( + AnnualReportsPresenter.new([report]), + serializer: REST::AnnualReportsSerializer, + scope: nil, + scope_name: :current_user + ).as_json + + payload[:me] = current_account.id.to_s if user_signed_in? + payload[:domain] = Addressable::IDNA.to_unicode(Rails.configuration.x.local_domain) + + json_string = payload.to_json + + # rubocop:disable Rails/OutputSafety + content_tag(:script, json_escape(json_string).html_safe, type: 'application/json', id: 'wrapstodon-data') + # rubocop:enable Rails/OutputSafety + end +end diff --git a/app/inputs/date_of_birth_input.rb b/app/inputs/date_of_birth_input.rb index 37f48133b30064..a7aec1b39bdcf3 100644 --- a/app/inputs/date_of_birth_input.rb +++ b/app/inputs/date_of_birth_input.rb @@ -1,31 +1,49 @@ # frozen_string_literal: true class DateOfBirthInput < SimpleForm::Inputs::Base - OPTIONS = [ - { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze, - { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze, - { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze, - ].freeze + OPTIONS = { + day: { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }, + month: { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }, + year: { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }, + }.freeze def input(wrapper_options = nil) merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) merged_input_options[:inputmode] = 'numeric' - values = (object.public_send(attribute_name) || '').to_s.split('-') - - safe_join(2.downto(0).map do |index| - options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index] - @builder.text_field("#{attribute_name}(#{index + 1}i)", options) - end) + safe_join( + ordered_options.map do |option| + options = merged_input_options + .merge(OPTIONS[option]) + .merge( + id: generate_id(option), + 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{param_for(option)}"), + value: values[option] + ) + @builder.text_field("#{attribute_name}(#{param_for(option)})", options) + end + ) end def label_target - "#{attribute_name}_3i" + "#{attribute_name}_#{param_for(ordered_options.first)}" end private - def generate_id(index) - "#{object_name}_#{attribute_name}_#{index + 1}i" + def ordered_options + I18n.t('date.order').map(&:to_sym) + end + + def generate_id(option) + "#{object_name}_#{attribute_name}_#{param_for(option)}" + end + + def param_for(option) + "#{ActionView::Helpers::DateTimeSelector::POSITION[option]}i" + end + + def values + Date._parse((object.public_send(attribute_name) || '').to_s).transform_keys(mon: :month, mday: :day) end end diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index a2c53d472b212d..92b9d1d9177ddb 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -256,9 +256,8 @@ async function mountReactComponent(element: Element) { const componentProps = JSON.parse(stringProps) as object; - const { default: AdminComponent } = await import( - '@/mastodon/containers/admin_component' - ); + const { default: AdminComponent } = + await import('@/mastodon/containers/admin_component'); const { default: Component } = (await import( `@/mastodon/components/admin/${componentName}.jsx` diff --git a/app/javascript/entrypoints/theme-selection.ts b/app/javascript/entrypoints/theme-selection.ts new file mode 100644 index 00000000000000..76e46e15f193b0 --- /dev/null +++ b/app/javascript/entrypoints/theme-selection.ts @@ -0,0 +1 @@ +import '../inline/theme-selection'; diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx new file mode 100644 index 00000000000000..9fff41a1330438 --- /dev/null +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -0,0 +1,67 @@ +import { createRoot } from 'react-dom/client'; + +import { Provider as ReduxProvider } from 'react-redux'; + +import { importFetchedStatuses } from '@/mastodon/actions/importer'; +import { hydrateStore } from '@/mastodon/actions/store'; +import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report'; +import { Router } from '@/mastodon/components/router'; +import { WrapstodonSharedPage } from '@/mastodon/features/annual_report/shared_page'; +import { IntlProvider, loadLocale } from '@/mastodon/locales'; +import { loadPolyfills } from '@/mastodon/polyfills'; +import ready from '@/mastodon/ready'; +import { setReport } from '@/mastodon/reducers/slices/annual_report'; +import { store } from '@/mastodon/store'; + +function loaded() { + const mountNode = document.getElementById('wrapstodon'); + if (!mountNode) { + throw new Error('Mount node not found'); + } + const propsNode = document.getElementById('wrapstodon-data'); + if (!propsNode) { + throw new Error('Initial state prop not found'); + } + + const initialState = JSON.parse( + propsNode.textContent, + ) as ApiAnnualReportResponse & { me?: string; domain: string }; + + const report = initialState.annual_reports[0]; + if (!report) { + throw new Error('Initial state report not found'); + } + + // Set up store + store.dispatch( + hydrateStore({ + meta: { + locale: document.documentElement.lang, + me: initialState.me, + domain: initialState.domain, + }, + accounts: initialState.accounts, + }), + ); + store.dispatch(importFetchedStatuses(initialState.statuses)); + + store.dispatch(setReport(report)); + + const root = createRoot(mountNode); + root.render( + + + + + + + , + ); +} + +loadPolyfills() + .then(loadLocale) + .then(() => ready(loaded)) + .catch((err: unknown) => { + console.error(err); + }); diff --git a/app/javascript/fonts/silkscreen-wrapstodon/OFL.txt b/app/javascript/fonts/silkscreen-wrapstodon/OFL.txt new file mode 100644 index 00000000000000..63c1c98e1e92b7 --- /dev/null +++ b/app/javascript/fonts/silkscreen-wrapstodon/OFL.txt @@ -0,0 +1,100 @@ +Below you'll find the original License file for the Silkscreen font. +The file used on Mastodon is a custom file subset to only include the +characters "Wrapstodon 0123456789" using the Font Squirrel Font-face Generator +(https://www.fontsquirrel.com/tools/webfont-generator) + +----------------------------------------------------------- + +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/javascript/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2 b/app/javascript/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2 new file mode 100644 index 00000000000000..3b7ba43e9c36f5 Binary files /dev/null and b/app/javascript/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2 differ diff --git a/app/javascript/images/archetypes/previews/booster.jpg b/app/javascript/images/archetypes/previews/booster.jpg new file mode 100644 index 00000000000000..fe74640b2e0e19 Binary files /dev/null and b/app/javascript/images/archetypes/previews/booster.jpg differ diff --git a/app/javascript/images/archetypes/previews/lurker.jpg b/app/javascript/images/archetypes/previews/lurker.jpg new file mode 100644 index 00000000000000..ba1897a2a95c6f Binary files /dev/null and b/app/javascript/images/archetypes/previews/lurker.jpg differ diff --git a/app/javascript/images/archetypes/previews/oracle.jpg b/app/javascript/images/archetypes/previews/oracle.jpg new file mode 100644 index 00000000000000..a18de034f45d52 Binary files /dev/null and b/app/javascript/images/archetypes/previews/oracle.jpg differ diff --git a/app/javascript/images/archetypes/previews/pollster.jpg b/app/javascript/images/archetypes/previews/pollster.jpg new file mode 100644 index 00000000000000..c53e5418671542 Binary files /dev/null and b/app/javascript/images/archetypes/previews/pollster.jpg differ diff --git a/app/javascript/images/archetypes/previews/replier.jpg b/app/javascript/images/archetypes/previews/replier.jpg new file mode 100644 index 00000000000000..086ee599798fc4 Binary files /dev/null and b/app/javascript/images/archetypes/previews/replier.jpg differ diff --git a/app/javascript/images/archetypes/space_elements.png b/app/javascript/images/archetypes/space_elements.png new file mode 100644 index 00000000000000..8b83506b8e7336 Binary files /dev/null and b/app/javascript/images/archetypes/space_elements.png differ diff --git a/app/javascript/images/icons/icon_admin.svg b/app/javascript/images/icons/icon_admin.svg new file mode 100644 index 00000000000000..7e40dc464374be --- /dev/null +++ b/app/javascript/images/icons/icon_admin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/javascript/images/icons/icon_pinned.svg b/app/javascript/images/icons/icon_pinned.svg new file mode 100644 index 00000000000000..90eaa6c933f6a5 --- /dev/null +++ b/app/javascript/images/icons/icon_pinned.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/images/icons/icon_planet.svg b/app/javascript/images/icons/icon_planet.svg new file mode 100644 index 00000000000000..e8bf34c7a66717 --- /dev/null +++ b/app/javascript/images/icons/icon_planet.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/images/icons/icon_verified.svg b/app/javascript/images/icons/icon_verified.svg new file mode 100644 index 00000000000000..65873b9dc43495 --- /dev/null +++ b/app/javascript/images/icons/icon_verified.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/javascript/inline/theme-selection.js b/app/javascript/inline/theme-selection.js new file mode 100644 index 00000000000000..680fbb23ec28f5 --- /dev/null +++ b/app/javascript/inline/theme-selection.js @@ -0,0 +1,24 @@ +(function (element) { + const {colorScheme, contrast} = element.dataset; + + const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)'); + const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)'); + + const updateColorScheme = () => { + const useDarkMode = colorScheme === 'auto' ? colorSchemeMediaWatcher.matches : colorScheme === 'dark'; + + element.dataset.colorScheme = useDarkMode ? 'dark' : 'light'; + }; + + const updateContrast = () => { + const useHighContrast = contrast === 'high' || contrastMediaWatcher.matches; + + element.dataset.contrast = useHighContrast ? 'high' : 'default'; + } + + colorSchemeMediaWatcher.addEventListener('change', updateColorScheme); + contrastMediaWatcher.addEventListener('change', updateContrast); + + updateColorScheme(); + updateContrast(); +})(document.documentElement); diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index b4157a502ef795..5960c3dc2a463e 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -153,7 +153,8 @@ export function fetchAccountFail(id, error) { */ export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { - const alreadyFollowing = getState().getIn(['relationships', id, 'following']); + const relationship = getState().getIn(['relationships', id]); + const alreadyFollowing = relationship?.following || relationship?.requested; const locked = getState().getIn(['accounts', id, 'locked'], false); dispatch(followAccountRequest({ id, locked })); diff --git a/app/javascript/mastodon/actions/bundles.js b/app/javascript/mastodon/actions/bundles.js deleted file mode 100644 index ecc9c8f7d3ec22..00000000000000 --- a/app/javascript/mastodon/actions/bundles.js +++ /dev/null @@ -1,25 +0,0 @@ -export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; -export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; -export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; - -export function fetchBundleRequest(skipLoading) { - return { - type: BUNDLE_FETCH_REQUEST, - skipLoading, - }; -} - -export function fetchBundleSuccess(skipLoading) { - return { - type: BUNDLE_FETCH_SUCCESS, - skipLoading, - }; -} - -export function fetchBundleFail(error, skipLoading) { - return { - type: BUNDLE_FETCH_FAIL, - error, - skipLoading, - }; -} diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index bd1cb3ca9bc3a5..6e39db4756c857 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -673,7 +673,16 @@ export function selectComposeSuggestion(position, token, suggestion, path) { dispatch(useEmoji(suggestion)); } else if (suggestion.type === 'hashtag') { - completion = token + suggestion.name.slice(token.length - 1); + // TODO: it could make sense to keep the “most capitalized” of the two + const tokenName = token.slice(1); // strip leading '#' + const suggestionPrefix = suggestion.name.slice(0, tokenName.length); + const prefixMatchesSuggestion = suggestionPrefix.localeCompare(tokenName, undefined, { sensitivity: 'accent' }) === 0; + if (prefixMatchesSuggestion) { + completion = token + suggestion.name.slice(tokenName.length); + } else { + completion = `${token.slice(0, 1)}${suggestion.name}`; + } + startPosition = position - 1; } else if (suggestion.type === 'account') { completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`; diff --git a/app/javascript/mastodon/actions/directory.ts b/app/javascript/mastodon/actions/directory.ts index 34ac309c66c4c6..a50a377ffcfa08 100644 --- a/app/javascript/mastodon/actions/directory.ts +++ b/app/javascript/mastodon/actions/directory.ts @@ -6,15 +6,17 @@ import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; +const DIRECTORY_FETCH_LIMIT = 20; + export const fetchDirectory = createDataLoadingThunk( 'directory/fetch', async (params: Parameters[0]) => - apiGetDirectory(params), + apiGetDirectory(params, DIRECTORY_FETCH_LIMIT), (data, { dispatch }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchRelationships(data.map((x) => x.id))); - return { accounts: data }; + return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT }; }, ); @@ -26,12 +28,15 @@ export const expandDirectory = createDataLoadingThunk( 'items', ]) as ImmutableList; - return apiGetDirectory({ ...params, offset: loadedItems.size }, 20); + return apiGetDirectory( + { ...params, offset: loadedItems.size }, + DIRECTORY_FETCH_LIMIT, + ); }, (data, { dispatch }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchRelationships(data.map((x) => x.id))); - return { accounts: data }; + return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT }; }, ); diff --git a/app/javascript/mastodon/actions/importer/emoji.ts b/app/javascript/mastodon/actions/importer/emoji.ts new file mode 100644 index 00000000000000..c4baa57d56c1c1 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/emoji.ts @@ -0,0 +1,22 @@ +import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; +import { loadCustomEmoji } from '@/mastodon/features/emoji'; + +export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { + if (emojis.length === 0) { + return; + } + + // First, check if we already have them all. + const { searchCustomEmojisByShortcodes, clearEtag } = + await import('@/mastodon/features/emoji/database'); + + const existingEmojis = await searchCustomEmojisByShortcodes( + emojis.map((emoji) => emoji.shortcode), + ); + + // If there's a mismatch, re-import all custom emojis. + if (existingEmojis.length < emojis.length) { + await clearEtag('custom'); + await loadCustomEmoji(); + } +} diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 6a85231bb69dbd..fe31b84bff0bde 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,6 +1,7 @@ import { createPollFromServerJSON } from 'mastodon/models/poll'; import { importAccounts } from './accounts'; +import { importCustomEmoji } from './emoji'; import { normalizeStatus } from './normalizer'; import { importPolls } from './polls'; @@ -39,6 +40,10 @@ export function importFetchedAccounts(accounts) { if (account.moved) { processAccount(account.moved); } + + if (account.emojis && account.username === account.acct) { + importCustomEmoji(account.emojis); + } } accounts.forEach(processAccount); @@ -80,6 +85,10 @@ export function importFetchedStatuses(statuses, options = {}) { if (status.card) { status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account)); } + + if (status.emojis && status.account.username === status.account.acct) { + importCustomEmoji(status.emojis); + } } statuses.forEach(processStatus); diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 075dc84ef1c56b..854865104ac84d 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -2,6 +2,8 @@ import escapeTextContentForBrowser from 'escape-html'; import { expandSpoilers } from '../../initial_state'; +import { importCustomEmoji } from './emoji'; + const domParser = new DOMParser(); export function searchTextFromRawStatus (status) { @@ -151,5 +153,9 @@ export function normalizeAnnouncement(announcement) { normalAnnouncement.contentHtml = normalAnnouncement.content; + if (normalAnnouncement.emojis) { + importCustomEmoji(normalAnnouncement.emojis); + } + return normalAnnouncement; } diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index d3538a8850a3d7..437f597314d9d7 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -6,6 +6,7 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; +import { timelineExpandPinnedFromStatus } from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -368,6 +369,7 @@ export function pin(status) { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -406,6 +408,7 @@ export function unpin (status) { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/mastodon/actions/search.ts b/app/javascript/mastodon/actions/search.ts index 1e57c307157d41..4f21a53b4d8230 100644 --- a/app/javascript/mastodon/actions/search.ts +++ b/app/javascript/mastodon/actions/search.ts @@ -144,7 +144,7 @@ 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; + const history = searchHistory.get(me); if (history !== null) { dispatch(updateSearchHistory(history)); diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index 32ee093afa8423..c291eb772a017c 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -27,7 +27,15 @@ export const fetchServer = () => (dispatch, getState) => { api() .get('/api/v2/instance').then(({ data }) => { - if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); + // Only import the account if it doesn't already exist, + // because the API is cached even for logged in users. + const account = data.contact.account; + if (account) { + const existingAccount = getState().getIn(['accounts', account.id]); + if (!existingAccount) { + dispatch(importFetchedAccount(account)); + } + } dispatch(fetchServerSuccess(data)); }).catch(err => dispatch(fetchServerFail(err))); }; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 5602fcadb694fb..b883e986ddd7e2 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -85,6 +85,8 @@ export function fetchStatus(id, { dispatch(fetchStatusSuccess(skipLoading)); }).catch(error => { dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId)); + if (error.status === 404) + dispatch(deleteFromTimelines(id)); }); }; } diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index e8fec134535a82..7a68679d4477a5 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -22,6 +22,8 @@ export function hydrateStore(rawState) { dispatch(hydrateCompose()); dispatch(hydrateSearch()); - dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + if (rawState.accounts) { + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + } }; } diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index f1d8dd9a97efdf..c6c2a864a8f454 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -6,7 +6,7 @@ import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { submitMarkers } from './markers'; -import {timelineDelete} from './timelines_typed'; +import { timelineDelete } from './timelines_typed'; export { disconnectTimeline } from './timelines_typed'; @@ -24,8 +24,16 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; export const TIMELINE_INSERT = 'TIMELINE_INSERT'; +// When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions'; export const TIMELINE_GAP = null; +export const TIMELINE_PINNED_VIEW_ALL = 'pinned-view-all'; + +export const TIMELINE_NON_STATUS_MARKERS = [ + TIMELINE_GAP, + TIMELINE_SUGGESTIONS, + TIMELINE_PINNED_VIEW_ALL, +]; export const loadPending = timeline => ({ type: TIMELINE_LOAD_PENDING, diff --git a/app/javascript/mastodon/actions/timelines.test.ts b/app/javascript/mastodon/actions/timelines.test.ts new file mode 100644 index 00000000000000..239692dd34faa5 --- /dev/null +++ b/app/javascript/mastodon/actions/timelines.test.ts @@ -0,0 +1,85 @@ +import { parseTimelineKey, timelineKey } from './timelines_typed'; + +describe('timelineKey', () => { + test('returns expected key for account timeline with filters', () => { + const key = timelineKey({ + type: 'account', + userId: '123', + replies: true, + boosts: false, + media: true, + }); + expect(key).toBe('account:123:0110'); + }); + + test('returns expected key for account timeline with tag', () => { + const key = timelineKey({ + type: 'account', + userId: '456', + tagged: 'nature', + replies: true, + }); + expect(key).toBe('account:456:0100:nature'); + }); + + test('returns expected key for account timeline with pins', () => { + const key = timelineKey({ + type: 'account', + userId: '789', + pinned: true, + }); + expect(key).toBe('account:789:0001'); + }); +}); + +describe('parseTimelineKey', () => { + test('parses account timeline key with filters correctly', () => { + const params = parseTimelineKey('account:123:1010'); + expect(params).toEqual({ + type: 'account', + userId: '123', + boosts: true, + replies: false, + media: true, + pinned: false, + }); + }); + + test('parses account timeline key with tag correctly', () => { + const params = parseTimelineKey('account:456:0100:nature'); + expect(params).toEqual({ + type: 'account', + userId: '456', + replies: true, + boosts: false, + media: false, + pinned: false, + tagged: 'nature', + }); + }); + + test('parses legacy account timeline key with pinned correctly', () => { + const params = parseTimelineKey('account:789:pinned:nature'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: false, + pinned: true, + tagged: 'nature', + }); + }); + + test('parses legacy account timeline key with media correctly', () => { + const params = parseTimelineKey('account:789:media'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: true, + pinned: false, + }); + }); +}); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index 07d82b2f015a7c..f07b1274e2fabc 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -1,7 +1,187 @@ import { createAction } from '@reduxjs/toolkit'; +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import type { Status } from '../models/status'; +import { createAppThunk } from '../store/typed_functions'; + +import { + expandAccountFeaturedTimeline, + expandTimeline, + TIMELINE_NON_STATUS_MARKERS, +} from './timelines'; + +export const expandTimelineByKey = createAppThunk( + (args: { key: string; maxId?: number }, { dispatch }) => { + const params = parseTimelineKey(args.key); + if (!params) { + return; + } + + void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId })); + }, +); + +export const expandTimelineByParams = createAppThunk( + (params: TimelineParams & { maxId?: number }, { dispatch }) => { + let url = ''; + const extra: Record = {}; + + if (params.type === 'account') { + url = `/api/v1/accounts/${params.userId}/statuses`; + + if (!params.replies) { + extra.exclude_replies = true; + } + if (!params.boosts) { + extra.exclude_reblogs = true; + } + if (params.pinned) { + extra.pinned = true; + } + if (params.media) { + extra.only_media = true; + } + if (params.tagged) { + extra.tagged = params.tagged; + } + } else if (params.type === 'public') { + url = '/api/v1/timelines/public'; + } + + if (params.maxId) { + extra.max_id = params.maxId.toString(); + } + + return dispatch(expandTimeline(timelineKey(params), url, extra)); + }, +); + +export interface AccountTimelineParams { + type: 'account'; + userId: string; + tagged?: string; + media?: boolean; + pinned?: boolean; + boosts?: boolean; + replies?: boolean; +} +export type PublicTimelineServer = 'local' | 'remote' | 'all'; +export interface PublicTimelineParams { + type: 'public'; + tagged?: string; + server?: PublicTimelineServer; // Defaults to 'all' + media?: boolean; +} +export interface HomeTimelineParams { + type: 'home'; +} +export type TimelineParams = + | AccountTimelineParams + | PublicTimelineParams + | HomeTimelineParams; + +const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const; + +export function timelineKey(params: TimelineParams): string { + const { type } = params; + const key: string[] = [type]; + + if (type === 'account') { + key.push(params.userId); + + const view = ACCOUNT_FILTERS.reduce( + (prev, curr) => prev + (params[curr] ? '1' : '0'), + '', + ); + + key.push(view); + } else if (type === 'public') { + key.push(params.server ?? 'all'); + if (params.media) { + key.push('media'); + } + } + + if (type !== 'home' && params.tagged) { + key.push(params.tagged); + } + + return key.filter(Boolean).join(':'); +} + +export function parseTimelineKey(key: string): TimelineParams | null { + const segments = key.split(':'); + const type = segments[0]; + + if (type === 'account') { + const userId = segments[1]; + if (!userId) { + return null; + } + + const parsed: TimelineParams = { + type: 'account', + userId, + tagged: segments[3], + pinned: false, + boosts: false, + replies: false, + media: false, + }; + + // Handle legacy keys. + const flagsSegment = segments[2]; + if (!flagsSegment || !/^[01]{4}$/.test(flagsSegment)) { + if (flagsSegment === 'pinned') { + parsed.pinned = true; + } else if (flagsSegment === 'with_replies') { + parsed.replies = true; + } else if (flagsSegment === 'media') { + parsed.media = true; + } + return parsed; + } + + const view = segments[2]?.split('') ?? []; + for (let i = 0; i < view.length; i++) { + const flagName = ACCOUNT_FILTERS[i]; + if (flagName) { + parsed[flagName] = view[i] === '1'; + } + } + return parsed; + } + + if (type === 'public') { + return { + type: 'public', + server: + segments[1] === 'remote' || segments[1] === 'local' + ? segments[1] + : 'all', + tagged: segments[2], + media: segments[3] === 'media', + }; + } + + if (type === 'home') { + return { type: 'home' }; + } + + return null; +} + +export function isTimelineKeyPinned(key: string) { + const parsedKey = parseTimelineKey(key); + return parsedKey?.type === 'account' && parsedKey.pinned; +} + +export function isNonStatusId(value: unknown) { + return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); +} + export const disconnectTimeline = createAction( 'timeline/disconnect', ({ timeline }: { timeline: string }) => ({ @@ -18,3 +198,53 @@ export const timelineDelete = createAction<{ references: string[]; reblogOf: string | null; }>('timelines/delete'); + +export const timelineExpandPinnedFromStatus = createAppThunk( + (status: Status, { dispatch, getState }) => { + const accountId = status.getIn(['account', 'id']) as string; + if (!accountId) { + return; + } + + // Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. + const timelines = getState().timelines as ImmutableMap; + if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + }), + ); + void dispatch(expandAccountFeaturedTimeline(accountId)); + + // Iterate over tags and clear those too. + const tags = status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined; + if (!tags) { + return; + } + tags.forEach((tag) => { + const tagName = tag.get('name'); + if (!tagName) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + tagged: tagName, + }), + ); + void dispatch( + expandAccountFeaturedTimeline(accountId, { tagged: tagName }), + ); + }); + }, +); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 1820e00a537fc5..2af29c783e08b8 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -128,15 +128,18 @@ export default function api(withAuthorization = true) { } type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; -type RequestParamsOrData = Record; +type RequestParamsOrData = T | Record; -export async function apiRequest( +export async function apiRequest< + ApiResponse = unknown, + ApiParamsOrData = unknown, +>( method: Method, url: string, args: { signal?: AbortSignal; - params?: RequestParamsOrData; - data?: RequestParamsOrData; + params?: RequestParamsOrData; + data?: RequestParamsOrData; timeout?: number; } = {}, ) { @@ -149,30 +152,30 @@ export async function apiRequest( return data; } -export async function apiRequestGet( +export async function apiRequestGet( url: ApiUrl, - params?: RequestParamsOrData, + params?: RequestParamsOrData, ) { return apiRequest('GET', url, { params }); } -export async function apiRequestPost( +export async function apiRequestPost( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('POST', url, { data }); } -export async function apiRequestPut( +export async function apiRequestPut( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('PUT', url, { data }); } -export async function apiRequestDelete( - url: ApiUrl, - params?: RequestParamsOrData, -) { +export async function apiRequestDelete< + ApiResponse = unknown, + ApiParams = unknown, +>(url: ApiUrl, params?: RequestParamsOrData) { return apiRequest('DELETE', url, { params }); } diff --git a/app/javascript/mastodon/api/annual_report.ts b/app/javascript/mastodon/api/annual_report.ts new file mode 100644 index 00000000000000..dc080035d49f8a --- /dev/null +++ b/app/javascript/mastodon/api/annual_report.ts @@ -0,0 +1,38 @@ +import api, { apiRequestGet, getAsyncRefreshHeader } from '../api'; +import type { ApiAccountJSON } from '../api_types/accounts'; +import type { ApiStatusJSON } from '../api_types/statuses'; +import type { AnnualReport } from '../models/annual_report'; + +export type ApiAnnualReportState = + | 'available' + | 'generating' + | 'eligible' + | 'ineligible'; + +export const apiGetAnnualReportState = async (year: number) => { + const response = await api().get<{ state: ApiAnnualReportState }>( + `/api/v1/annual_reports/${year}/state`, + ); + + return { + state: response.data.state, + refresh: getAsyncRefreshHeader(response), + }; +}; + +export const apiRequestGenerateAnnualReport = async (year: number) => { + const response = await api().post(`/api/v1/annual_reports/${year}/generate`); + + return { + refresh: getAsyncRefreshHeader(response), + }; +}; + +export interface ApiAnnualReportResponse { + annual_reports: AnnualReport[]; + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; +} + +export const apiGetAnnualReport = (year: number) => + apiRequestGet(`v1/annual_reports/${year}`); diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts new file mode 100644 index 00000000000000..8e3ceb73897325 --- /dev/null +++ b/app/javascript/mastodon/api/collections.ts @@ -0,0 +1,39 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; + +import type { + ApiWrappedCollectionJSON, + ApiCollectionWithAccountsJSON, + ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, + ApiCollectionsJSON, +} from '../api_types/collections'; + +export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => + apiRequestPost('v1_alpha/collections', collection); + +export const apiUpdateCollection = ({ + id, + ...collection +}: ApiUpdateCollectionPayload) => + apiRequestPut( + `v1_alpha/collections/${id}`, + collection, + ); + +export const apiDeleteCollection = (collectionId: string) => + apiRequestDelete(`v1_alpha/collections/${collectionId}`); + +export const apiGetCollection = (collectionId: string) => + apiRequestGet( + `v1_alpha/collections/${collectionId}`, + ); + +export const apiGetAccountCollections = (accountId: string) => + apiRequestGet( + `v1_alpha/accounts/${accountId}/collections`, + ); diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts new file mode 100644 index 00000000000000..cded45f1a3b160 --- /dev/null +++ b/app/javascript/mastodon/api_types/collections.ts @@ -0,0 +1,83 @@ +// See app/serializers/rest/base_collection_serializer.rb + +import type { ApiAccountJSON } from './accounts'; +import type { ApiTagJSON } from './statuses'; + +/** + * Returned when fetching all collections for an account, + * doesn't contain account and item data + */ +export interface ApiCollectionJSON { + account_id: string; + + id: string; + uri: string; + local: boolean; + item_count: number; + + name: string; + description: string; + tag?: ApiTagJSON; + language: string; + sensitive: boolean; + discoverable: boolean; + + created_at: string; + updated_at: string; + + items: CollectionAccountItem[]; +} + +/** + * Returned when fetching all collections for an account + */ +export interface ApiCollectionsJSON { + collections: ApiCollectionJSON[]; +} + +/** + * Returned when creating, updating, and adding to a collection + */ +export interface ApiWrappedCollectionJSON { + collection: ApiCollectionJSON; +} + +/** + * Returned when fetching a single collection + */ +export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON { + accounts: ApiAccountJSON[]; +} + +/** + * Nested account item + */ +interface CollectionAccountItem { + account_id?: string; // Only present when state is 'accepted' (or the collection is your own) + state: 'pending' | 'accepted' | 'rejected' | 'revoked'; + position: number; +} + +export interface WrappedCollectionAccountItem { + collection_item: CollectionAccountItem; +} + +/** + * Payload types + */ + +type CommonPayloadFields = Pick< + ApiCollectionJSON, + 'name' | 'description' | 'sensitive' | 'discoverable' +> & { + tag_name?: string | null; + language?: ApiCollectionJSON['language']; +}; + +export interface ApiUpdateCollectionPayload extends Partial { + id: string; +} + +export interface ApiCreateCollectionPayload extends CommonPayloadFields { + account_ids?: string[]; +} diff --git a/app/javascript/mastodon/api_types/custom_emoji.ts b/app/javascript/mastodon/api_types/custom_emoji.ts index 05144d6f68d0e8..099ef0b88b8f25 100644 --- a/app/javascript/mastodon/api_types/custom_emoji.ts +++ b/app/javascript/mastodon/api_types/custom_emoji.ts @@ -1,8 +1,9 @@ -// See app/serializers/rest/account_serializer.rb +// See app/serializers/rest/custom_emoji_serializer.rb export interface ApiCustomEmojiJSON { shortcode: string; static_url: string; url: string; category?: string; + featured?: boolean; visible_in_picker: boolean; } diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 533e9903682e79..d698a1a6991b11 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -102,8 +102,7 @@ export interface ApiAccountWarningJSON { appeal: unknown; } -interface ModerationWarningNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface ModerationWarningNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'moderation_warning'; moderation_warning: ApiAccountWarningJSON; } @@ -123,14 +122,12 @@ export interface ApiAccountRelationshipSeveranceEventJSON { created_at: string; } -interface AccountRelationshipSeveranceNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface AccountRelationshipSeveranceNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } -interface AccountRelationshipSeveranceNotificationJSON - extends BaseNotificationJSON { +interface AccountRelationshipSeveranceNotificationJSON extends BaseNotificationJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } diff --git a/app/javascript/mastodon/api_types/relationships.ts b/app/javascript/mastodon/api_types/relationships.ts index 9f26a0ce9b333d..aa871d6f792fdf 100644 --- a/app/javascript/mastodon/api_types/relationships.ts +++ b/app/javascript/mastodon/api_types/relationships.ts @@ -8,8 +8,9 @@ export interface ApiRelationshipJSON { following: boolean; id: string; languages: string[] | null; - muting_notifications: boolean; muting: boolean; + muting_notifications: boolean; + muting_expires_at: string | null; note: string; notifying: boolean; requested_by: boolean; diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 00418a13d29d40..d61d8ceed061b5 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -41,21 +41,20 @@ export interface ApiPreviewCardJSON { url: string; title: string; description: string; - language: string; - type: string; + language: string | null; + type: 'video' | 'link'; author_name: string; author_url: string; - author_account?: ApiAccountJSON; provider_name: string; provider_url: string; html: string; width: number; height: number; - image: string; + image: string | null; image_description: string; embed_url: string; blurhash: string; - published_at: string; + published_at: string | null; authors: ApiPreviewCardAuthorJSON[]; } diff --git a/app/javascript/mastodon/common.ts b/app/javascript/mastodon/common.ts index e621a24e39fe46..33d2b5ad171f40 100644 --- a/app/javascript/mastodon/common.ts +++ b/app/javascript/mastodon/common.ts @@ -1,9 +1,5 @@ -import Rails from '@rails/ujs'; +import { setupLinkListeners } from './utils/links'; export function start() { - try { - Rails.start(); - } catch { - // If called twice - } + setupLinkListeners(); } diff --git a/app/javascript/mastodon/components/account/account.stories.tsx b/app/javascript/mastodon/components/account/account.stories.tsx index 3a3a255b7fc582..050ed6e9005967 100644 --- a/app/javascript/mastodon/components/account/account.stories.tsx +++ b/app/javascript/mastodon/components/account/account.stories.tsx @@ -1,16 +1,28 @@ +import type { ComponentProps } from 'react'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import { accountFactoryState, relationshipsFactory } from '@/testing/factories'; import { Account } from './index'; +type Props = Omit, 'id'> & { + name: string; + username: string; +}; + const meta = { title: 'Components/Account', - component: Account, argTypes: { - id: { + name: { + type: 'string', + description: 'The display name of the account', + reduxPath: 'accounts.1.display_name_html', + }, + username: { type: 'string', - description: 'ID of the account to display', + description: 'The username of the account', + reduxPath: 'accounts.1.acct', }, size: { type: 'number', @@ -40,7 +52,8 @@ const meta = { }, }, args: { - id: '1', + name: 'Test User', + username: 'testuser', size: 46, hidden: false, minimal: false, @@ -55,17 +68,16 @@ const meta = { }, }, }, -} satisfies Meta; + render(args) { + return ; + }, +} satisfies Meta; export default meta; type Story = StoryObj; -export const Primary: Story = { - args: { - id: '1', - }, -}; +export const Primary: Story = {}; export const Hidden: Story = { args: { diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx index dd17b89d865865..9ddbfd058f9c0c 100644 --- a/app/javascript/mastodon/components/account_fields.tsx +++ b/app/javascript/mastodon/components/account_fields.tsx @@ -6,7 +6,6 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { Icon } from 'mastodon/components/icon'; import type { Account } from 'mastodon/models/account'; -import { CustomEmojiProvider } from './emoji/context'; import { EmojiHTML } from './emoji/html'; import { useElementHandledLink } from './status/handled_link'; @@ -22,12 +21,13 @@ export const AccountFields: React.FC> = ({ } return ( - + <> {fields.map((pair, i) => (
@@ -52,12 +52,13 @@ export const AccountFields: React.FC> = ({
))} -
+ ); }; diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index 267c0442158323..9e342a353a169e 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -28,7 +28,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { return [null, null]; } - word = word.trim().toLowerCase(); + word = word.trim(); if (word.length > 0) { return [left + 1, word]; @@ -159,8 +159,8 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.input.focus(); }; - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + componentDidUpdate (prevProps) { + if (prevProps.suggestions !== this.props.suggestions && this.props.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { this.setState({ suggestionsHidden: false }); } } diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index a9acc87252d89c..fae078da31b379 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -29,7 +29,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => { return [null, null]; } - word = word.trim().toLowerCase(); + word = word.trim(); if (word.length > 0) { return [left + 1, word]; diff --git a/app/javascript/mastodon/components/badge.jsx b/app/javascript/mastodon/components/badge.jsx deleted file mode 100644 index 2a335d7f5062fb..00000000000000 --- a/app/javascript/mastodon/components/badge.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; -import PersonIcon from '@/material-icons/400-24px/person.svg?react'; -import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; - - -export const Badge = ({ icon = , label, domain, roleId }) => ( -
- {icon} - {label} - {domain && {domain}} -
-); - -Badge.propTypes = { - icon: PropTypes.node, - label: PropTypes.node, - domain: PropTypes.node, - roleId: PropTypes.string -}; - -export const GroupBadge = () => ( - } label={} /> -); - -export const AutomatedBadge = () => ( - } label={} /> -); diff --git a/app/javascript/mastodon/components/badge.stories.tsx b/app/javascript/mastodon/components/badge.stories.tsx new file mode 100644 index 00000000000000..6c4921809cbbbf --- /dev/null +++ b/app/javascript/mastodon/components/badge.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import CelebrationIcon from '@/material-icons/400-24px/celebration-fill.svg?react'; + +import * as badges from './badge'; + +const meta = { + component: badges.Badge, + title: 'Components/Badge', + args: { + label: undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Example', + }, +}; + +export const Domain: Story = { + args: { + ...Default.args, + domain: 'example.com', + }, +}; + +export const CustomIcon: Story = { + args: { + ...Default.args, + icon: , + }, +}; + +export const Admin: Story = { + args: { + roleId: '1', + }, + render(args) { + return ; + }, +}; + +export const Group: Story = { + render(args) { + return ; + }, +}; + +export const Automated: Story = { + render(args) { + return ; + }, +}; + +export const Muted: Story = { + render(args) { + return ; + }, +}; + +export const MutedWithDate: Story = { + render(args) { + const futureDate = new Date(new Date().getFullYear(), 11, 31).toISOString(); + return ; + }, +}; + +export const Blocked: Story = { + render(args) { + return ; + }, +}; diff --git a/app/javascript/mastodon/components/badge.tsx b/app/javascript/mastodon/components/badge.tsx new file mode 100644 index 00000000000000..07ecdfa46ccd1f --- /dev/null +++ b/app/javascript/mastodon/components/badge.tsx @@ -0,0 +1,124 @@ +import type { FC, ReactNode } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import AdminIcon from '@/images/icons/icon_admin.svg?react'; +import BlockIcon from '@/material-icons/400-24px/block.svg?react'; +import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; +import PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; +import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react'; + +interface BadgeProps { + label: ReactNode; + icon?: ReactNode; + className?: string; + domain?: ReactNode; + roleId?: string; +} + +export const Badge: FC = ({ + icon = , + label, + className, + domain, + roleId, +}) => ( +
+ {icon} + {label} + {domain && {domain}} +
+); + +export const AdminBadge: FC> = ({ label, ...props }) => ( + } + label={ + label ?? ( + + ) + } + {...props} + /> +); + +export const GroupBadge: FC> = ({ label, ...props }) => ( + } + label={ + label ?? ( + + ) + } + {...props} + /> +); + +export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => ( + } + label={ + + } + className={className} + /> +); + +export const MutedBadge: FC< + Partial & { expiresAt?: string | null } +> = ({ expiresAt, label, ...props }) => { + // Format the date, only showing the year if it's different from the current year. + const intl = useIntl(); + let formattedDate: string | null = null; + if (expiresAt) { + const expiresDate = new Date(expiresAt); + const isCurrentYear = + expiresDate.getFullYear() === new Date().getFullYear(); + formattedDate = intl.formatDate(expiresDate, { + month: 'short', + day: 'numeric', + ...(isCurrentYear ? {} : { year: 'numeric' }), + }); + } + return ( + } + label={ + label ?? + (formattedDate ? ( + + ) : ( + + )) + } + {...props} + /> + ); +}; + +export const BlockedBadge: FC> = ({ label, ...props }) => ( + } + label={ + label ?? ( + + ) + } + {...props} + /> +); diff --git a/app/javascript/mastodon/components/button/index.tsx b/app/javascript/mastodon/components/button/index.tsx index ca2da05b703f8d..a75449b0d58399 100644 --- a/app/javascript/mastodon/components/button/index.tsx +++ b/app/javascript/mastodon/components/button/index.tsx @@ -5,8 +5,10 @@ import classNames from 'classnames'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -interface BaseProps - extends Omit, 'children'> { +interface BaseProps extends Omit< + React.ButtonHTMLAttributes, + 'children' +> { block?: boolean; secondary?: boolean; plain?: boolean; diff --git a/app/javascript/mastodon/components/callout/callout.stories.tsx b/app/javascript/mastodon/components/callout/callout.stories.tsx new file mode 100644 index 00000000000000..f9bba1ec141c95 --- /dev/null +++ b/app/javascript/mastodon/components/callout/callout.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { Callout } from '.'; + +const meta = { + title: 'Components/Callout', + args: { + children: 'Contents here', + title: 'Title', + onPrimary: action('Primary action clicked'), + primaryLabel: 'Primary', + onSecondary: action('Secondary action clicked'), + secondaryLabel: 'Secondary', + onClose: action('Close clicked'), + }, + component: Callout, + render(args) { + return ( +
+ +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: 'default', + }, +}; + +export const NoIcon: Story = { + args: { + icon: false, + }, +}; + +export const NoActions: Story = { + args: { + onPrimary: undefined, + onSecondary: undefined, + }, +}; + +export const OnlyText: Story = { + args: { + onClose: undefined, + onPrimary: undefined, + onSecondary: undefined, + icon: false, + }, +}; + +// export const Subtle: Story = { +// args: { +// variant: 'subtle', +// }, +// }; + +export const Feature: Story = { + args: { + variant: 'feature', + }, +}; + +export const Inverted: Story = { + args: { + variant: 'inverted', + }, +}; + +export const Success: Story = { + args: { + variant: 'success', + }, +}; + +export const Warning: Story = { + args: { + variant: 'warning', + }, +}; + +export const Error: Story = { + args: { + variant: 'error', + }, +}; diff --git a/app/javascript/mastodon/components/callout/dismissible.tsx b/app/javascript/mastodon/components/callout/dismissible.tsx new file mode 100644 index 00000000000000..70a5c850b61317 --- /dev/null +++ b/app/javascript/mastodon/components/callout/dismissible.tsx @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { useDismissible } from '@/mastodon/hooks/useDismissible'; + +import { Callout } from '.'; +import type { CalloutProps } from '.'; + +type DismissibleCalloutProps = CalloutProps & { + id: string; +}; + +export const DismissibleCallout: FC = (props) => { + const { dismiss, wasDismissed } = useDismissible(props.id); + + const { onClose } = props; + const handleClose = useCallback(() => { + dismiss(); + onClose?.(); + }, [dismiss, onClose]); + + if (wasDismissed) { + return null; + } + + return ; +}; diff --git a/app/javascript/mastodon/components/callout/index.tsx b/app/javascript/mastodon/components/callout/index.tsx new file mode 100644 index 00000000000000..a9232ec3a7a8f3 --- /dev/null +++ b/app/javascript/mastodon/components/callout/index.tsx @@ -0,0 +1,154 @@ +import type { FC, ReactNode } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import ErrorIcon from '@/material-icons/400-24px/error.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; + +import type { IconProp } from '../icon'; +import { Icon } from '../icon'; +import { IconButton } from '../icon_button'; + +import classes from './styles.module.css'; + +export interface CalloutProps { + variant?: + | 'default' + // | 'subtle' + | 'feature' + | 'inverted' + | 'success' + | 'warning' + | 'error'; + title?: ReactNode; + children: ReactNode; + className?: string; + /** Set to false to hide the icon. */ + icon?: IconProp | boolean; + onPrimary?: () => void; + primaryLabel?: string; + onSecondary?: () => void; + secondaryLabel?: string; + onClose?: () => void; + id?: string; + extraContent?: ReactNode; +} + +const variantClasses = { + default: classes.variantDefault as string, + // subtle: classes.variantSubtle as string, + feature: classes.variantFeature as string, + inverted: classes.variantInverted as string, + success: classes.variantSuccess as string, + warning: classes.variantWarning as string, + error: classes.variantError as string, +} as const; + +export const Callout: FC = ({ + className, + variant = 'default', + title, + children, + icon, + onPrimary: primaryAction, + primaryLabel, + onSecondary: secondaryAction, + secondaryLabel, + onClose, + extraContent, + id, +}) => { + const intl = useIntl(); + + return ( + + ); +}; + +const CalloutIcon: FC> = ({ + variant = 'default', + icon, +}) => { + if (icon === false) { + return null; + } + + if (!icon || icon === true) { + switch (variant) { + case 'inverted': + case 'success': + icon = CheckIcon; + break; + case 'warning': + icon = WarningIcon; + break; + case 'error': + icon = ErrorIcon; + break; + default: + icon = InfoIcon; + } + } + + return ; +}; diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css new file mode 100644 index 00000000000000..7f33c96eae8736 --- /dev/null +++ b/app/javascript/mastodon/components/callout/styles.module.css @@ -0,0 +1,128 @@ +.wrapper { + display: flex; + align-items: start; + padding: 12px; + gap: 8px; + background-color: var(--color-bg-brand-softer); + color: var(--color-text-primary); + border-radius: 12px; +} + +.icon { + padding: 4px; + border-radius: 9999px; + width: 1rem; + height: 1rem; + margin-top: -2px; +} + +.content { + display: flex; + gap: 8px; + flex-direction: column; + flex-grow: 1; +} + +@media screen and (width >= 630px) { + .content { + flex-direction: row; + } +} + +.body { + flex-grow: 1; + + h3 { + font-weight: 500; + margin-bottom: 5px; + } +} + +.actionWrapper { + display: flex; + gap: 8px; + align-items: start; +} + +.action { + appearance: none; + background: none; + border: none; + color: inherit; + font-weight: 500; + padding: 0; + text-decoration: underline; + transition: color 0.1s ease-in-out; + + &:hover { + color: var(--color-text-brand-soft); + } +} + +@media (prefers-reduced-motion: reduce) { + .action { + transition: none; + } +} + +.close { + color: inherit; + + svg { + width: 20px; + height: 20px; + } +} + +.variantDefault { + .icon { + background-color: var(--color-bg-brand-soft); + } +} + +/* .variantSubtle { + border: 1px solid var(--color-bg-brand-softer); + background-color: var(--color-bg-primary); + + .icon { + background-color: var(--color-bg-brand-softer); + } +} */ + +.variantFeature { + background-color: var(--color-bg-brand-base); + color: var(--color-text-on-brand-base); + + button:hover { + color: color-mix(var(--color-text-on-brand-base), transparent 20%); + } +} + +.variantInverted { + background-color: var(--color-bg-inverted); + color: var(--color-text-on-inverted); +} + +.variantSuccess { + background-color: var(--color-bg-success-softer); + + .icon { + background-color: var(--color-bg-success-soft); + } +} + +.variantWarning { + background-color: var(--color-bg-warning-softer); + + .icon { + background-color: var(--color-bg-warning-soft); + } +} + +.variantError { + background-color: var(--color-bg-error-softer); + + .icon { + background-color: var(--color-bg-error-soft); + } +} diff --git a/app/javascript/mastodon/components/content_warning.tsx b/app/javascript/mastodon/components/content_warning.tsx index a407ec146e3e12..2d3222f479a50c 100644 --- a/app/javascript/mastodon/components/content_warning.tsx +++ b/app/javascript/mastodon/components/content_warning.tsx @@ -31,7 +31,7 @@ export const ContentWarning: React.FC<{ } + extraEmojis={status.get('emojis') as List} /> ); diff --git a/app/javascript/mastodon/components/dismissable_banner.tsx b/app/javascript/mastodon/components/dismissable_banner.tsx index a874f4792e4c7f..39ae11422a3c2b 100644 --- a/app/javascript/mastodon/components/dismissable_banner.tsx +++ b/app/javascript/mastodon/components/dismissable_banner.tsx @@ -1,12 +1,10 @@ -import type { PropsWithChildren } from 'react'; -import { useCallback, useState, useEffect } from 'react'; +import type { FC, ReactNode } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { changeSetting } from 'mastodon/actions/settings'; -import { bannerSettings } from 'mastodon/settings'; -import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { useDismissible } from '../hooks/useDismissible'; import { IconButton } from './icon_button'; @@ -16,48 +14,12 @@ const messages = defineMessages({ interface Props { id: string; + children: ReactNode; } -export function useDismissableBannerState({ id }: Props) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const dismissed: boolean = useAppSelector((state) => - /* eslint-disable-next-line */ - state.settings.getIn(['dismissed_banners', id], false), - ); - - const [isVisible, setIsVisible] = useState( - !bannerSettings.get(id) && !dismissed, - ); - - const dispatch = useAppDispatch(); - - const dismiss = useCallback(() => { - setIsVisible(false); - bannerSettings.set(id, true); - dispatch(changeSetting(['dismissed_banners', id], true)); - }, [id, dispatch]); - - useEffect(() => { - // Store legacy localStorage setting on server - if (!isVisible && !dismissed) { - dispatch(changeSetting(['dismissed_banners', id], true)); - } - }, [id, dispatch, isVisible, dismissed]); - - return { - wasDismissed: !isVisible, - dismiss, - }; -} - -export const DismissableBanner: React.FC> = ({ - id, - children, -}) => { +export const DismissableBanner: FC = ({ id, children }) => { const intl = useIntl(); - const { wasDismissed, dismiss } = useDismissableBannerState({ - id, - }); + const { wasDismissed, dismiss } = useDismissible(id); if (wasDismissed) { return null; diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index dacbc33789523c..6ed138c301595f 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -71,10 +71,15 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ return null; } - const { text, description, icon } = item; + const { text, description, icon, iconId } = item; return ( <> - {icon && } + {icon && ( + + )} {text} {Boolean(description) && ( @@ -309,7 +314,7 @@ interface DropdownProps { renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onOpen?: // Must use a union type for the full function as a union with void is not allowed. - | ((event: React.MouseEvent | React.KeyboardEvent) => void) + | ((event: React.MouseEvent | React.KeyboardEvent) => void) | ((event: React.MouseEvent | React.KeyboardEvent) => boolean); onItemClick?: ItemClickFn; } diff --git a/app/javascript/mastodon/components/emoji/emoji.stories.tsx b/app/javascript/mastodon/components/emoji/emoji.stories.tsx index a5e283158d6121..e7d1887aa9a3a8 100644 --- a/app/javascript/mastodon/components/emoji/emoji.stories.tsx +++ b/app/javascript/mastodon/components/emoji/emoji.stories.tsx @@ -2,24 +2,27 @@ import type { ComponentProps } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { importCustomEmojiData } from '@/mastodon/features/emoji/loader'; +import { customEmojiFactory } from '@/testing/factories'; +import { CustomEmojiProvider } from './context'; import { Emoji } from './index'; -type EmojiProps = ComponentProps & { state: string }; +type EmojiProps = ComponentProps & { + style: 'auto' | 'native' | 'twemoji'; +}; const meta = { title: 'Components/Emoji', component: Emoji, args: { code: '🖤', - state: 'auto', + style: 'auto', }, argTypes: { code: { name: 'Emoji', }, - state: { + style: { control: { type: 'select', labels: { @@ -30,16 +33,15 @@ const meta = { }, options: ['auto', 'native', 'twemoji'], name: 'Emoji Style', - mapping: { - auto: { meta: { emoji_style: 'auto' } }, - native: { meta: { emoji_style: 'native' } }, - twemoji: { meta: { emoji_style: 'twemoji' } }, - }, + reduxPath: 'meta.emoji_style', }, }, render(args) { - void importCustomEmojiData(); - return ; + return ( + + + + ); }, } satisfies Meta; diff --git a/app/javascript/mastodon/components/emoji/index.tsx b/app/javascript/mastodon/components/emoji/index.tsx index 0dff8314ffc5ff..9b917df6ea0eb7 100644 --- a/app/javascript/mastodon/components/emoji/index.tsx +++ b/app/javascript/mastodon/components/emoji/index.tsx @@ -1,9 +1,17 @@ import type { FC } from 'react'; import { useContext, useEffect, useState } from 'react'; -import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants'; +import classNames from 'classnames'; + +import { + EMOJI_TYPE_CUSTOM, + EMOJI_TYPE_UNICODE, +} from '@/mastodon/features/emoji/constants'; import { useEmojiAppState } from '@/mastodon/features/emoji/mode'; -import { unicodeHexToUrl } from '@/mastodon/features/emoji/normalize'; +import { + emojiToInversionClassName, + unicodeHexToUrl, +} from '@/mastodon/features/emoji/normalize'; import { isStateLoaded, loadEmojiDataToState, @@ -41,6 +49,7 @@ export const Emoji: FC = ({ }, [appState.currentLocale, state]); const animate = useContext(AnimateEmojiContext); + const fallback = showFallback ? code : null; // If the code is invalid or we otherwise know it's not valid, show the fallback. @@ -48,10 +57,6 @@ export const Emoji: FC = ({ return fallback; } - if (!shouldRenderImage(state, appState.mode)) { - return code; - } - if (!isStateLoaded(state)) { if (showLoading) { return ; @@ -59,6 +64,17 @@ export const Emoji: FC = ({ return fallback; } + const inversionClass = + state.type === EMOJI_TYPE_UNICODE && + emojiToInversionClassName(state.data.unicode); + + if (!shouldRenderImage(state, appState.mode)) { + if (state.type === EMOJI_TYPE_UNICODE) { + return state.data.unicode; + } + return code; + } + if (state.type === EMOJI_TYPE_CUSTOM) { const shortcode = `:${state.code}:`; return ( @@ -72,14 +88,17 @@ export const Emoji: FC = ({ ); } - const src = unicodeHexToUrl(state.code, appState.darkTheme); + const src = unicodeHexToUrl({ + unicodeHex: state.code, + ...appState, + }); return ( {state.data.unicode} ); diff --git a/app/javascript/mastodon/components/empty_state/empty_state.module.scss b/app/javascript/mastodon/components/empty_state/empty_state.module.scss new file mode 100644 index 00000000000000..1707b3bc0818d0 --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/empty_state.module.scss @@ -0,0 +1,23 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + max-width: 600px; + padding: 20px; + gap: 16px; + text-align: center; + color: var(--color-text-primary); +} + +.content { + h3 { + font-size: 17px; + font-weight: 500; + } + + p { + font-size: 15px; + margin-top: 8px; + color: var(--color-text-secondary); + } +} diff --git a/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx new file mode 100644 index 00000000000000..8515a6ea1add89 --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { Button } from '../button'; + +import { EmptyState } from '.'; + +const meta = { + title: 'Components/EmptyState', + component: EmptyState, + argTypes: { + title: { + control: 'text', + type: 'string', + table: { + type: { summary: 'string' }, + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + message: 'Try clearing filters or refreshing the page.', + }, +}; + +export const WithoutMessage: Story = { + args: { + message: undefined, + }, +}; + +export const WithAction: Story = { + args: { + ...Default.args, + // eslint-disable-next-line react/jsx-no-bind + children: , + }, +}; diff --git a/app/javascript/mastodon/components/empty_state/index.tsx b/app/javascript/mastodon/components/empty_state/index.tsx new file mode 100644 index 00000000000000..93f034f3e9373b --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/index.tsx @@ -0,0 +1,32 @@ +import { FormattedMessage } from 'react-intl'; + +import classes from './empty_state.module.scss'; + +/** + * Simple empty state component with a neutral default title and customisable message. + * + * Action buttons can be passed as `children` + */ + +export const EmptyState: React.FC<{ + title?: string | React.ReactElement; + message?: string | React.ReactElement; + children?: React.ReactNode; +}> = ({ + title = ( + + ), + message, + children, +}) => { + return ( +
+
+

{title}

+ {!!message &&

{message}

} +
+ + {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 97aaecd1aac902..ffb2ed1e5bff4f 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -59,7 +59,14 @@ export const FollowButton: React.FC<{ compact?: boolean; labelLength?: 'auto' | 'short' | 'long'; className?: string; -}> = ({ accountId, compact, labelLength = 'auto', className }) => { + withUnmute?: boolean; +}> = ({ + accountId, + compact, + labelLength = 'auto', + className, + withUnmute = true, +}) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { signedIn } = useIdentity(); @@ -94,7 +101,14 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; - } else if (relationship.muting) { + } else if (relationship.blocking) { + dispatch( + openModal({ + modalType: 'CONFIRM_UNBLOCK', + modalProps: { account }, + }), + ); + } else if (relationship.muting && withUnmute) { dispatch(unmuteAccount(accountId)); } else if (account && relationship.following) { dispatch( @@ -107,17 +121,10 @@ export const FollowButton: React.FC<{ modalProps: { account }, }), ); - } else if (relationship.blocking) { - dispatch( - openModal({ - modalType: 'CONFIRM_UNBLOCK', - modalProps: { account }, - }), - ); } else { dispatch(followAccount(accountId)); } - }, [dispatch, accountId, relationship, account, signedIn]); + }, [signedIn, relationship, accountId, withUnmute, account, dispatch]); const isNarrow = useBreakpoint('narrow'); const useShortLabel = @@ -136,7 +143,7 @@ export const FollowButton: React.FC<{ label = intl.formatMessage(messages.editProfile); } else if (!relationship) { label = ; - } else if (relationship.muting) { + } else if (relationship.muting && withUnmute) { label = intl.formatMessage(messages.unmute); } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); @@ -173,7 +180,7 @@ export const FollowButton: React.FC<{ (!(relationship?.following || relationship?.requested) && (account?.suspended || !!account?.moved)) } - secondary={following} + secondary={following || relationship?.blocking} compact={compact} className={classNames(className, { 'button--destructive': following })} > diff --git a/app/javascript/mastodon/components/form_fields/checkbox.module.scss b/app/javascript/mastodon/components/form_fields/checkbox.module.scss new file mode 100644 index 00000000000000..8f4ab99a5c6623 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/checkbox.module.scss @@ -0,0 +1,82 @@ +.checkbox { + --size: 16px; + --border-width: 1px; + + appearance: none; + box-sizing: border-box; + position: relative; + display: inline-flex; + margin: 0; + width: var(--size); + height: var(--size); + vertical-align: top; + border-radius: calc(var(--size) / 4); + border: var(--border-width) solid var(--color-border-primary); + background-color: var(--color-bg-primary); + transition: 0.15s ease-out; + transition-property: background-color, border-color; + cursor: pointer; + + /* Increase clickable area, prevents misclicks and covers gap between control and label */ + &::after { + content: ''; + position: absolute; + + --spread: calc(var(--border-width) + var(--form-field-label-gap, 8px)); + + inset-inline: calc(-1 * var(--spread)); + inset-block: calc(-0.75 * var(--spread)); + } + + &:disabled { + background: var(--color-bg-tertiary); + border: none; + cursor: not-allowed; + } + + /* Tick icon */ + &::before { + content: ''; + opacity: 0; + background-color: var(--color-text-on-brand-base); + display: block; + margin: auto; + width: calc(var(--size) * 0.625); + height: calc(var(--size) * 0.5); + mask-image: url("data:image/svg+xml;utf8,"); + mask-position: center; + mask-size: 100%; + mask-repeat: no-repeat; + } + + /* 'Minus' icon */ + &:indeterminate::before { + width: calc(var(--size) * 0.5); + height: calc(var(--size) * 0.125); + mask-image: url("data:image/svg+xml;utf8,"); + } + + &:checked, + &:indeterminate { + background-color: var(--color-bg-brand-base); + border-color: var(--color-bg-brand-base); + + &:disabled { + border: none; + background-color: var(--color-text-disabled); + + &::before { + background-color: var(--color-bg-tertiary); + } + } + + &::before { + opacity: 1; + } + } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: 2px; + } +} diff --git a/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx b/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx new file mode 100644 index 00000000000000..4d208cf21b6d4e --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Checkbox, CheckboxField } from './checkbox_field'; +import { Fieldset } from './fieldset'; + +const meta = { + title: 'Components/Form Fields/CheckboxField', + component: CheckboxField, + args: { + label: 'Label', + hint: 'This is a description of this form field', + disabled: false, + }, + argTypes: { + size: { + control: { type: 'range', min: 10, max: 64, step: 1 }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = {}; + +export const WithoutHint: Story = { + args: { + hint: undefined, + }, +}; + +export const InFieldset: Story = { + render() { + return ( +
+ + + +
+ ); + }, +}; + +export const InFieldsetHorizontal: Story = { + render() { + return ( +
+ + + +
+ ); + }, +}; + +export const Required: Story = { + args: { + required: true, + }, +}; + +export const Optional: Story = { + args: { + required: false, + }, +}; + +export const WithError: Story = { + args: { + required: false, + hasError: true, + }, +}; + +export const DisabledChecked: Story = { + args: { + disabled: true, + checked: true, + }, +}; + +export const DisabledUnchecked: Story = { + args: { + disabled: true, + checked: false, + }, +}; + +export const Indeterminate: Story = { + args: { + indeterminate: true, + }, +}; + +export const Plain: Story = { + render(props) { + return ; + }, +}; + +export const Small: Story = { + args: { + size: 14, + }, +}; + +export const Large: Story = { + args: { + size: 36, + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/checkbox_field.tsx b/app/javascript/mastodon/components/form_fields/checkbox_field.tsx new file mode 100644 index 00000000000000..2b6933c8473146 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/checkbox_field.tsx @@ -0,0 +1,65 @@ +import type { ComponentPropsWithoutRef, CSSProperties } from 'react'; +import { forwardRef, useCallback, useEffect, useRef } from 'react'; + +import classes from './checkbox.module.scss'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; +import { FormFieldWrapper } from './form_field_wrapper'; + +type Props = Omit, 'type'> & { + size?: number; + indeterminate?: boolean; +}; + +export const CheckboxField = forwardRef< + HTMLInputElement, + Props & CommonFieldWrapperProps +>(({ id, label, hint, hasError, required, ...otherProps }, ref) => ( + + {(inputProps) => } + +)); + +CheckboxField.displayName = 'CheckboxField'; + +export const Checkbox = forwardRef( + ({ className, size, indeterminate, ...otherProps }, ref) => { + const inputRef = useRef(null); + + const handleRef = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate || false; + } + }, [indeterminate]); + + return ( + + ); + }, +); + +Checkbox.displayName = 'Checkbox'; diff --git a/app/javascript/mastodon/components/form_fields/combobox.module.scss b/app/javascript/mastodon/components/form_fields/combobox.module.scss new file mode 100644 index 00000000000000..98c0db9f610e5c --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/combobox.module.scss @@ -0,0 +1,69 @@ +.wrapper { + position: relative; +} + +.input { + padding-right: 45px; +} + +.menuButton { + position: absolute; + inset-inline-end: 0; + top: 0; + padding: 9px; + + &::before { + // Subtle divider line separating the button from the input field + content: ''; + position: absolute; + inset-inline-start: 0; + inset-block: 10px; + border-inline-start: 1px solid var(--color-border-primary); + } +} + +.popover { + z-index: 9999; + box-sizing: border-box; + padding: 4px; + border-radius: 4px; + color: var(--color-text-primary); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-primary); + box-shadow: var(--dropdown-shadow); + + // backdrop-filter: $backdrop-blur-filter; +} + +.menuItem { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; + font-size: 14px; + line-height: 20px; + border-radius: 4px; + color: var(--color-text-primary); + cursor: pointer; + user-select: none; + + &[aria-selected='true'] { + color: var(--color-text-on-brand-base); + background: var(--color-bg-brand-base); + + &[aria-disabled='true'] { + color: var(--color-text-on-disabled); + background: var(--color-bg-disabled); + } + } + + &[aria-disabled='true'] { + color: var(--color-text-disabled); + cursor: not-allowed; + } +} + +.emptyMessage { + padding: 8px 16px; + font-size: 13px; +} diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx new file mode 100644 index 00000000000000..412428d345f0a5 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx @@ -0,0 +1,92 @@ +import { useCallback, useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ComboboxField } from './combobox_field'; + +const ComboboxDemo: React.FC = () => { + const [searchValue, setSearchValue] = useState(''); + + const items = [ + { id: '1', name: 'Apple' }, + { id: '2', name: 'Banana' }, + { id: '3', name: 'Cherry', disabled: true }, + { id: '4', name: 'Date' }, + { id: '5', name: 'Fig', disabled: true }, + { id: '6', name: 'Grape' }, + { id: '7', name: 'Honeydew' }, + { id: '8', name: 'Kiwi' }, + { id: '9', name: 'Lemon' }, + { id: '10', name: 'Mango' }, + { id: '11', name: 'Nectarine' }, + { id: '12', name: 'Orange' }, + { id: '13', name: 'Papaya' }, + { id: '14', name: 'Quince' }, + { id: '15', name: 'Raspberry' }, + { id: '16', name: 'Strawberry' }, + { id: '17', name: 'Tangerine' }, + { id: '19', name: 'Vanilla bean' }, + { id: '20', name: 'Watermelon' }, + { id: '22', name: 'Yellow Passion Fruit' }, + { id: '23', name: 'Zucchini' }, + { id: '24', name: 'Cantaloupe' }, + { id: '25', name: 'Blackberry' }, + { id: '26', name: 'Persimmon' }, + { id: '27', name: 'Lychee' }, + { id: '28', name: 'Dragon Fruit' }, + { id: '29', name: 'Passion Fruit' }, + { id: '30', name: 'Starfruit' }, + ]; + type Fruit = (typeof items)[number]; + + const getItemId = useCallback((item: Fruit) => item.id, []); + const getIsItemDisabled = useCallback((item: Fruit) => !!item.disabled, []); + + const handleSearchValueChange = useCallback( + (event: React.ChangeEvent) => { + setSearchValue(event.target.value); + }, + [], + ); + + const selectFruit = useCallback((selectedItem: Fruit) => { + setSearchValue(selectedItem.name); + }, []); + + const renderItem = useCallback( + (fruit: Fruit) => {fruit.name}, + [], + ); + + // Don't filter results if an exact match has been entered + const shouldFilterResults = !items.find((item) => searchValue === item.name); + const results = shouldFilterResults + ? items.filter((item) => + item.name.toLowerCase().includes(searchValue.toLowerCase()), + ) + : items; + + return ( + + ); +}; + +const meta = { + title: 'Components/Form Fields/ComboboxField', + component: ComboboxDemo, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = {}; diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx new file mode 100644 index 00000000000000..7ddd0194697ce6 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -0,0 +1,408 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import { forwardRef, useCallback, useId, useRef, useState } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + +import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; +import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react'; +import { matchWidth } from 'mastodon/components/dropdown/utils'; +import { IconButton } from 'mastodon/components/icon_button'; +import { useOnClickOutside } from 'mastodon/hooks/useOnClickOutside'; + +import classes from './combobox.module.scss'; +import { FormFieldWrapper } from './form_field_wrapper'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; +import { TextInput } from './text_input_field'; + +interface Item { + id: string; +} + +interface ComboboxProps< + T extends Item, +> extends ComponentPropsWithoutRef<'input'> { + value: string; + onChange: React.ChangeEventHandler; + isLoading?: boolean; + items: T[]; + getItemId: (item: T) => string; + getIsItemDisabled?: (item: T) => boolean; + renderItem: (item: T) => React.ReactElement; + onSelectItem: (item: T) => void; +} + +interface Props + extends ComboboxProps, CommonFieldWrapperProps {} + +/** + * The combobox field allows users to select one or multiple items + * from a large list of options by searching or filtering. + */ + +export const ComboboxFieldWithRef = ( + { id, label, hint, hasError, required, ...otherProps }: Props, + ref: React.ForwardedRef, +) => ( + + {(inputProps) => } + +); + +// Using a type assertion to maintain the full type signature of ComboboxWithRef +// (including its generic type) after wrapping it with `forwardRef`. +export const ComboboxField = forwardRef(ComboboxFieldWithRef) as { + ( + props: Props & { ref?: React.ForwardedRef }, + ): ReturnType; + displayName: string; +}; + +ComboboxField.displayName = 'ComboboxField'; + +const ComboboxWithRef = ( + { + value, + isLoading = false, + items, + getItemId, + getIsItemDisabled, + renderItem, + onSelectItem, + onChange, + onKeyDown, + className, + ...otherProps + }: ComboboxProps, + ref: React.ForwardedRef, +) => { + const intl = useIntl(); + const wrapperRef = useRef(null); + const inputRef = useRef(); + + const [highlightedItemId, setHighlightedItemId] = useState( + null, + ); + const [shouldMenuOpen, setShouldMenuOpen] = useState(false); + + const statusMessage = useGetA11yStatusMessage({ + value, + isLoading, + itemCount: items.length, + }); + const showStatusMessageInMenu = + !!statusMessage && value.length > 0 && items.length === 0; + const hasMenuContent = items.length > 0 || showStatusMessageInMenu; + const isMenuOpen = shouldMenuOpen && hasMenuContent; + + const openMenu = useCallback(() => { + setShouldMenuOpen(true); + }, []); + + const closeMenu = useCallback(() => { + setShouldMenuOpen(false); + }, []); + + const resetHighlight = useCallback(() => { + const firstItem = items[0]; + const firstItemId = firstItem ? getItemId(firstItem) : null; + setHighlightedItemId(firstItemId); + }, [getItemId, items]); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e); + resetHighlight(); + setShouldMenuOpen(!!e.target.value); + }, + [onChange, resetHighlight], + ); + + const handleHighlightItem = useCallback( + (e: React.MouseEvent) => { + const { itemId } = e.currentTarget.dataset; + if (itemId) { + setHighlightedItemId(itemId); + } + }, + [], + ); + + const selectItem = useCallback( + (itemId: string | null) => { + const item = items.find((item) => item.id === itemId); + if (item) { + const isDisabled = getIsItemDisabled?.(item) ?? false; + if (!isDisabled) { + onSelectItem(item); + } + } + inputRef.current?.focus(); + }, + [getIsItemDisabled, items, onSelectItem], + ); + + const handleSelectItem = useCallback( + (e: React.MouseEvent) => { + const { itemId } = e.currentTarget.dataset; + selectItem(itemId ?? null); + }, + [selectItem], + ); + + const selectHighlightedItem = useCallback(() => { + selectItem(highlightedItemId); + }, [highlightedItemId, selectItem]); + + const moveHighlight = useCallback( + (direction: number) => { + if (items.length === 0) { + return; + } + const highlightedItemIndex = items.findIndex( + (item) => getItemId(item) === highlightedItemId, + ); + if (highlightedItemIndex === -1) { + // If no item is highlighted yet, highlight the first or last + if (direction > 0) { + const firstItem = items.at(0); + setHighlightedItemId(firstItem ? getItemId(firstItem) : null); + } else { + const lastItem = items.at(-1); + setHighlightedItemId(lastItem ? getItemId(lastItem) : null); + } + } else { + // If there is a highlighted item, select the next or previous item + // and wrap around at the start or end: + let newIndex = highlightedItemIndex + direction; + if (newIndex >= items.length) { + newIndex = 0; + } else if (newIndex < 0) { + newIndex = items.length - 1; + } + + const newHighlightedItem = items[newIndex]; + setHighlightedItemId( + newHighlightedItem ? getItemId(newHighlightedItem) : null, + ); + } + }, + [getItemId, highlightedItemId, items], + ); + + useOnClickOutside(wrapperRef, closeMenu); + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + onKeyDown?.(e); + + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (isMenuOpen) { + moveHighlight(-1); + } else { + openMenu(); + } + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (isMenuOpen) { + moveHighlight(1); + } else { + openMenu(); + } + } + if (e.key === 'Tab') { + if (isMenuOpen) { + selectHighlightedItem(); + closeMenu(); + } + } + if (e.key === 'Enter') { + if (isMenuOpen) { + e.preventDefault(); + selectHighlightedItem(); + closeMenu(); + } + } + if (e.key === 'Escape') { + if (isMenuOpen) { + e.preventDefault(); + closeMenu(); + } + } + }, + [ + closeMenu, + isMenuOpen, + moveHighlight, + onKeyDown, + openMenu, + selectHighlightedItem, + ], + ); + + const mergeRefs = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + + const id = useId(); + const listId = `${id}-list`; + + return ( +
+ + {hasMenuContent && ( + + )} + + {isMenuOpen && statusMessage} + + } + container={wrapperRef} + popperConfig={{ + modifiers: [matchWidth], + }} + > + {({ props, placement }) => ( +
+ {showStatusMessageInMenu ? ( + {statusMessage} + ) : ( +
    + {items.map((item) => { + const id = getItemId(item); + const isDisabled = getIsItemDisabled?.(item); + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
  • + {renderItem(item)} +
  • + ); + })} +
+ )} +
+ )} +
+
+ ); +}; + +// Using a type assertion to maintain the full type signature of ComboboxWithRef +// (including its generic type) after wrapping it with `forwardRef`. +export const Combobox = forwardRef(ComboboxWithRef) as { + ( + props: ComboboxProps & { ref?: React.ForwardedRef }, + ): ReturnType; + displayName: string; +}; + +Combobox.displayName = 'Combobox'; + +function useGetA11yStatusMessage({ + itemCount, + value, + isLoading, +}: { + itemCount: number; + value: string; + isLoading: boolean; +}): string { + const intl = useIntl(); + + if (isLoading) { + return intl.formatMessage({ + id: 'combobox.loading', + defaultMessage: 'Loading', + }); + } + + if (value.length && !itemCount) { + return intl.formatMessage({ + id: 'combobox.no_results_found', + defaultMessage: 'No results for this search', + }); + } + + if (itemCount > 0) { + return intl.formatMessage( + { + id: 'combobox.results_available', + defaultMessage: + '{count, plural, one {# suggestion} other {# suggestions}} available. Use up and down arrow keys to navigate. Press Enter key to select.', + }, + { + count: itemCount, + }, + ); + } + return ''; +} diff --git a/app/javascript/mastodon/components/form_fields/fieldset.module.scss b/app/javascript/mastodon/components/form_fields/fieldset.module.scss new file mode 100644 index 00000000000000..f222762af51c3f --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/fieldset.module.scss @@ -0,0 +1,19 @@ +.fieldset { + display: flex; + flex-direction: column; + gap: 12px; + color: var(--color-text-primary); + font-size: 15px; +} + +.fieldsWrapper { + display: flex; + flex-direction: column; + row-gap: 8px; + + &[data-layout='horizontal'] { + flex-direction: row; + flex-wrap: wrap; + column-gap: 24px; + } +} diff --git a/app/javascript/mastodon/components/form_fields/fieldset.tsx b/app/javascript/mastodon/components/form_fields/fieldset.tsx new file mode 100644 index 00000000000000..d52a95130b13ef --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/fieldset.tsx @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + +import type { ReactNode, FC } from 'react'; +import { createContext, useId } from 'react'; + +import classes from './fieldset.module.scss'; +import formFieldWrapperClasses from './form_field_wrapper.module.scss'; + +interface FieldsetProps { + legend: ReactNode; + hint?: ReactNode; + name?: string; + hasError?: boolean; + layout?: 'vertical' | 'horizontal'; + children: ReactNode; +} + +export const FieldsetNameContext = createContext(undefined); + +/** + * A fieldset suitable for wrapping a group of checkboxes, + * radio buttons, or other grouped form controls. + */ + +export const Fieldset: FC = ({ + legend, + hint, + name, + hasError, + layout, + children, +}) => { + const uniqueId = useId(); + const labelId = `${uniqueId}-label`; + const hintId = `${uniqueId}-hint`; + const fieldsetName = name || `${uniqueId}-fieldset-name`; + const hasHint = !!hint; + + return ( +
+
+
+ {legend} +
+ {hasHint && ( +

+ {hint} +

+ )} +
+ +
+ + {children} + +
+
+ ); +}; diff --git a/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss b/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss new file mode 100644 index 00000000000000..faeb48aae4f62b --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss @@ -0,0 +1,51 @@ +.wrapper { + --form-field-label-gap: 6px; + + display: flex; + flex-direction: column; + gap: var(--form-field-label-gap); + color: var(--color-text-primary); + font-size: 15px; + + &[data-input-placement^='inline'] { + flex-direction: row; + + --form-field-label-gap: 8px; + } + + &[data-input-placement='inline-start'] { + align-items: start; + } + + &[data-input-placement='inline-end'] { + align-items: center; + } +} + +.labelWrapper { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 4px; +} + +.label { + font-weight: 500; + + &[data-has-parent-fieldset='true'] { + font-weight: normal; + } + + [data-has-error='true'] & { + color: var(--color-text-error); + } +} + +.hint { + color: var(--color-text-secondary); + font-size: 13px; +} + +.inputWrapper { + display: block; +} diff --git a/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx b/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx new file mode 100644 index 00000000000000..ec7c2e584b5bcf --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + +import type { ReactNode, FC } from 'react'; +import { useContext, useId } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { FieldsetNameContext } from './fieldset'; +import classes from './form_field_wrapper.module.scss'; + +interface InputProps { + id: string; + required?: boolean; + 'aria-describedby'?: string; +} + +interface FieldWrapperProps { + label: ReactNode; + hint?: ReactNode; + required?: boolean; + hasError?: boolean; + inputId?: string; + inputPlacement?: 'inline-start' | 'inline-end'; + children: (inputProps: InputProps) => ReactNode; +} + +/** + * These types can be extended when creating individual field components. + */ +export type CommonFieldWrapperProps = Pick< + FieldWrapperProps, + 'label' | 'hint' | 'hasError' +>; + +/** + * A simple form field wrapper for adding a label and hint to enclosed components. + * Accepts an optional `hint` and can be marked as required + * or optional (by explicitly setting `required={false}`) + */ + +export const FormFieldWrapper: FC = ({ + inputId: inputIdProp, + label, + hint, + required, + hasError, + inputPlacement, + children, +}) => { + const uniqueId = useId(); + const inputId = inputIdProp || `${uniqueId}-input`; + const hintId = `${inputIdProp || uniqueId}-hint`; + const hasHint = !!hint; + + const hasParentFieldset = !!useContext(FieldsetNameContext); + + const inputProps: InputProps = { + required, + id: inputId, + }; + if (hasHint) { + inputProps['aria-describedby'] = hintId; + } + + const input = ( +
{children(inputProps)}
+ ); + + return ( +
+ {inputPlacement === 'inline-start' && input} + +
+ + + {hasHint && ( + + {hint} + + )} +
+ + {inputPlacement !== 'inline-start' && input} +
+ ); +}; + +/** + * If `required` is explicitly set to `false` rather than `undefined`, + * the field will be visually marked as "optional". + */ + +const RequiredMark: FC<{ required?: boolean }> = ({ required }) => + required ? ( + <> + {' '} + + + ) : ( + <> + {' '} + + + ); diff --git a/app/javascript/mastodon/components/form_fields/form_stack.module.scss b/app/javascript/mastodon/components/form_fields/form_stack.module.scss new file mode 100644 index 00000000000000..083e36c32069b2 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/form_stack.module.scss @@ -0,0 +1,7 @@ +.stack { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 25px; + padding: 16px; +} diff --git a/app/javascript/mastodon/components/form_fields/form_stack.tsx b/app/javascript/mastodon/components/form_fields/form_stack.tsx new file mode 100644 index 00000000000000..707545898e6ad5 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/form_stack.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; + +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import classes from './form_stack.module.scss'; + +/** + * A simple wrapper for providing consistent spacing to a group of form fields. + */ + +export const FormStack = polymorphicForwardRef<'div'>( + ({ as: Element = 'div', children, className, ...otherProps }, ref) => ( + + {children} + + ), +); + +FormStack.displayName = 'FormStack'; diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts new file mode 100644 index 00000000000000..ef4a6567e5d4f5 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -0,0 +1,9 @@ +export { FormStack } from './form_stack'; +export { Fieldset } from './fieldset'; +export { TextInputField, TextInput } from './text_input_field'; +export { TextAreaField, TextArea } from './text_area_field'; +export { CheckboxField, Checkbox } from './checkbox_field'; +export { ComboboxField, Combobox } from './combobox_field'; +export { RadioButtonField, RadioButton } from './radio_button_field'; +export { ToggleField, Toggle } from './toggle_field'; +export { SelectField, Select } from './select_field'; diff --git a/app/javascript/mastodon/components/form_fields/radio_button.module.scss b/app/javascript/mastodon/components/form_fields/radio_button.module.scss new file mode 100644 index 00000000000000..aaac5404b9dc49 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/radio_button.module.scss @@ -0,0 +1,51 @@ +.radioButton { + --size: 16px; + --border-width: calc(var(--size) / 4); + + appearance: none; + box-sizing: border-box; + position: relative; + display: inline-flex; + margin: 0; + width: var(--size); + height: var(--size); + vertical-align: top; + border-radius: 100%; + border: var(--border-width) solid transparent; + box-shadow: 0 0 0 1px var(--color-border-primary); + background-color: var(--color-bg-primary); + transition: 0.15s ease-out; + transition-property: border-color; + cursor: pointer; + + /* Increase clickable area, prevents misclicks and covers gap between control and label */ + &::after { + content: ''; + position: absolute; + + --spread: calc(var(--border-width) + var(--form-field-label-gap, 8px)); + + inset-inline: calc(-1 * var(--spread)); + inset-block: calc(-0.75 * var(--spread)); + } + + &:disabled { + background: var(--color-bg-tertiary); + box-shadow: none; + cursor: not-allowed; + } + + &:checked { + border-color: var(--color-bg-brand-base); + box-shadow: none; + + &:disabled { + border-color: var(--color-text-disabled); + } + } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: 2px; + } +} diff --git a/app/javascript/mastodon/components/form_fields/radio_button_field.stories.tsx b/app/javascript/mastodon/components/form_fields/radio_button_field.stories.tsx new file mode 100644 index 00000000000000..95687abff324c7 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/radio_button_field.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Fieldset } from './fieldset'; +import { RadioButton, RadioButtonField } from './radio_button_field'; + +const meta = { + title: 'Components/Form Fields/RadioButtonField', + component: RadioButtonField, + args: { + label: 'Label', + hint: 'This is a description of this form field', + checked: false, + disabled: false, + }, + argTypes: { + size: { + control: { type: 'range', min: 10, max: 64, step: 1 }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = {}; + +export const WithoutHint: Story = { + args: { + hint: undefined, + }, +}; + +export const InFieldset: Story = { + render() { + return ( +
+ + + +
+ ); + }, +}; + +export const InFieldsetHorizontal: Story = { + render() { + return ( +
+ + + +
+ ); + }, +}; + +export const Optional: Story = { + args: { + required: false, + }, +}; + +export const WithError: Story = { + args: { + required: false, + hasError: true, + }, +}; + +export const DisabledChecked: Story = { + args: { + disabled: true, + checked: true, + }, +}; + +export const DisabledUnchecked: Story = { + args: { + disabled: true, + checked: false, + }, +}; + +export const Plain: Story = { + render(props) { + return ; + }, +}; + +export const Small: Story = { + args: { + size: 14, + }, +}; + +export const Large: Story = { + args: { + size: 36, + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/radio_button_field.tsx b/app/javascript/mastodon/components/form_fields/radio_button_field.tsx new file mode 100644 index 00000000000000..51f52168e06ec3 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/radio_button_field.tsx @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + +import type { ComponentPropsWithoutRef, CSSProperties } from 'react'; +import { forwardRef, useContext } from 'react'; + +import { FieldsetNameContext } from './fieldset'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; +import { FormFieldWrapper } from './form_field_wrapper'; +import classes from './radio_button.module.scss'; + +type Props = Omit, 'type'> & { + size?: number; +}; + +export const RadioButtonField = forwardRef< + HTMLInputElement, + Props & CommonFieldWrapperProps +>(({ id, label, hint, hasError, required, ...otherProps }, ref) => { + const fieldsetName = useContext(FieldsetNameContext); + + return ( + + {(inputProps) => ( + + )} + + ); +}); + +RadioButtonField.displayName = 'RadioButtonField'; + +export const RadioButton = forwardRef( + ({ className, size, ...otherProps }, ref) => ( + + ), +); + +RadioButton.displayName = 'RadioButton'; diff --git a/app/javascript/mastodon/components/form_fields/select.module.scss b/app/javascript/mastodon/components/form_fields/select.module.scss new file mode 100644 index 00000000000000..e68e248fec60d7 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/select.module.scss @@ -0,0 +1,66 @@ +.wrapper { + position: relative; + width: 100%; + + /* Dropdown indicator icon */ + &::after { + --icon-size: 11px; + + content: ''; + position: absolute; + top: 0; + bottom: 0; + inset-inline-end: 9px; + width: var(--icon-size); + background-color: var(--color-text-tertiary); + pointer-events: none; + mask-image: url("data:image/svg+xml;utf8,"); + mask-position: right center; + mask-size: var(--icon-size); + mask-repeat: no-repeat; + } + + &:has(.select:focus-visible)::after { + background-color: var(--color-text-secondary); + } + + &:has(.select:disabled)::after { + background-color: var(--color-text-disabled); + } +} + +.select { + appearance: none; + box-sizing: border-box; + display: block; + width: 100%; + height: 41px; + padding-inline-start: 10px; + padding-inline-end: 30px; + font-family: inherit; + font-size: 14px; + color: var(--color-text-primary); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-primary); + border-radius: 4px; + outline: 0; + + @media screen and (width <= 600px) { + font-size: 16px; + } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: -1px; + } + + &:disabled { + color: var(--color-text-disabled); + border-color: transparent; + cursor: not-allowed; + } + + [data-has-error='true'] & { + border-color: var(--color-text-error); + } +} diff --git a/app/javascript/mastodon/components/form_fields/select_field.stories.tsx b/app/javascript/mastodon/components/form_fields/select_field.stories.tsx new file mode 100644 index 00000000000000..469238dd44d9f4 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/select_field.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SelectField, Select } from './select_field'; + +const meta = { + title: 'Components/Form Fields/SelectField', + component: SelectField, + args: { + label: 'Fruit preference', + hint: 'Select your favourite fruit or not. Up to you.', + children: ( + <> + + + + + + + + + + + ), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = {}; + +export const WithoutHint: Story = { + args: { + hint: undefined, + }, +}; + +export const Required: Story = { + args: { + required: true, + }, +}; + +export const Optional: Story = { + args: { + required: false, + }, +}; + +export const WithError: Story = { + args: { + required: false, + hasError: true, + }, +}; + +export const Plain: Story = { + render(args) { + return + {children} + + )} + + ), +); + +SelectField.displayName = 'SelectField'; + +export const Select = forwardRef< + HTMLSelectElement, + ComponentPropsWithoutRef<'select'> +>(({ className, size, ...otherProps }, ref) => ( +
+