diff --git a/.browserslistrc b/.browserslistrc index 0135379d6ea26d..483713a03ec76b 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,6 +1,6 @@ defaults -> 0.2% -firefox >= 78 +> 0.2% and not ios < 15.6 +firefox >= 91 ios >= 15.6 not dead not OperaMini all diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3aa0bbf7da4ec3..97e1f4036d6e10 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # For details, see https://github.com/devcontainers/images/tree/main/src/ruby -FROM mcr.microsoft.com/devcontainers/ruby:1-3.3-bookworm +FROM mcr.microsoft.com/devcontainers/ruby:4.0-trixie # Install node version from .nvmrc WORKDIR /app @@ -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/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index ced5ecfe884c88..5a147d222f3f08 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -56,7 +56,7 @@ services: - internal_network es: - image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2 + image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15 restart: unless-stopped environment: ES_JAVA_OPTS: -Xms512m -Xmx512m @@ -73,7 +73,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.6.2 + image: libretranslate/libretranslate:v1.7.3 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000000000..4ae31f2169b828 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/sshd:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", + "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" + } + } +} diff --git a/.env.production.sample b/.env.production.sample index f687053d502403..e341c07801f721 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -318,24 +318,3 @@ MAX_POLL_OPTION_CHARS=100 # ----------------------- IP_RETENTION_PERIOD=31556952 SESSION_RETENTION_PERIOD=31556952 - -# Fetch All Replies Behavior -# -------------------------- -# When a user expands a post (DetailedStatus view), fetch all of its replies -# (default: false) -FETCH_REPLIES_ENABLED=false - -# Period to wait between fetching replies (in minutes) -FETCH_REPLIES_COOLDOWN_MINUTES=15 - -# Period to wait after a post is first created before fetching its replies (in minutes) -FETCH_REPLIES_INITIAL_WAIT_MINUTES=5 - -# Max number of replies to fetch - total, recursively through a whole reply tree -FETCH_REPLIES_MAX_GLOBAL=1000 - -# Max number of replies to fetch - for a single post -FETCH_REPLIES_MAX_SINGLE=500 - -# Max number of replies Collection pages to fetch - total -FETCH_REPLIES_MAX_PAGES=500 diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml index 5235796b58a536..baf95e2857683c 100644 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -59,7 +59,7 @@ body: Any additional technical details you may have, like logs or error traces value: | If this is happening on your own Mastodon server, please fill out those: - - Ruby version: (from `ruby --version`, eg. v3.4.4) + - Ruby version: (from `ruby --version`, eg. v4.0.5) - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false diff --git a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml index 72ae76af00c6e2..ea1963abc2b49f 100644 --- a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -60,7 +60,7 @@ body: value: | Please at least include those informations: - Operating system: (eg. Ubuntu 24.04.2) - - Ruby version: (from `ruby --version`, eg. v3.4.4) + - Ruby version: (from `ruby --version`, eg. v4.0.5) - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 808adc7de64f96..0188c2edd97978 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -9,21 +9,21 @@ runs: using: 'composite' steps: - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.nvmrc' # The following is needed because we can not use `cache: true` for `setup-node`, as it does not support Corepack yet and mess up with the cache location if ran after Node is installed - name: Enable corepack shell: bash - run: corepack enable + run: npm i -g corepack - name: Get yarn cache directory path id: yarn-cache-dir-path shell: bash run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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..f26448f9ed8cd0 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -1,4 +1,4 @@ -name: 'Setup RUby' +name: 'Setup Ruby' description: 'Setup a Ruby environment ready to run the Mastodon code' inputs: ruby-version: @@ -14,10 +14,16 @@ runs: shell: bash run: | sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} + sudo apt-get install --no-install-recommends -y \ + libicu-dev \ + libidn11-dev \ + libvips42 \ + libheif-plugin-aomdec \ + libheif-plugin-libde265 \ + ${{ inputs.additional-system-dependencies }} - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: ruby-version: ${{ inputs.ruby-version }} bundler-cache: true diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c1a1c99eb708e7..a418c7ef026298 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -5,7 +5,6 @@ 'customManagers:dockerfileVersions', ':labels(dependencies)', ':prConcurrentLimitNone', // Remove limit for open PRs at any time. - ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. ':enableVulnerabilityAlertsWithLabel(security)', ], rebaseWhen: 'conflicted', @@ -16,6 +15,13 @@ // to `null` after any other rule set it to something. dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).', postUpdateOptions: ['yarnDedupeHighest'], + devcontainer: { + managerFilePatterns: [ + '/^\.devcontainer\/devcontainer\.json$/', + '/^\.devcontainer\.json$/', + '/^\.devcontainer\/devcontainer-lock\.json$/', + ], + }, // The types are now included in recent versions,we ignore them here until we upgrade and remove the dependency ignoreDeps: ['@types/emoji-mart'], packageRules: [ @@ -23,8 +29,6 @@ // Require Dependency Dashboard Approval for major version bumps of these node packages matchManagers: ['npm'], matchPackageNames: [ - 'tesseract.js', // Requires code changes - // react-router: Requires manual upgrade 'history', 'react-router-dom', @@ -116,6 +120,7 @@ ], matchUpdateTypes: ['major'], groupName: 'artifact actions (major)', + extends: ['helpers:pinGitHubActionDigests'], }, { // Update @types/* packages every week, with one grouped PR @@ -156,9 +161,15 @@ groupName: 'opentelemetry-ruby (non-major)', }, { - // Group Playwright Ruby & JS deps in the same PR, as they need to be in sync - matchManagers: ['bundler', 'npm'], - matchPackageNames: ['playwright-ruby-client', 'playwright'], + // The ruby portion of the Playwright group + matchManagers: ['bundler'], + matchPackageNames: ['playwright-ruby-client'], + groupName: 'Playwright', + }, + { + // The node portion of the Playwright group + matchManagers: ['npm'], + matchPackageNames: ['playwright'], groupName: 'Playwright', }, // Add labels depending on package manager diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 260730004cc774..8db68a7cda29ba 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@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Prepare env: @@ -45,21 +45,22 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV # Transform multi-line variable into comma-separated variable image_names=${PUSH_TO_IMAGES//$'\n'/,} - echo "IMAGE_NAMES=${image_names%,}" >> $GITHUB_ENV + image_names_split=${image_names%,} + echo "IMAGE_NAMES=${image_names_split,,}" >> $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 +68,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 +77,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ inputs.file_to_build }} @@ -87,8 +88,8 @@ jobs: platforms: ${{ matrix.platform }} provenance: false push: ${{ inputs.push_to_images != '' }} - cache-from: ${{ inputs.cache && 'type=gha' || '' }} - cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} + cache-from: ${{ inputs.cache && format('type=gha,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }} + cache-to: ${{ inputs.cache && format('type=gha,mode=max,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }} outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }} - name: Export digest @@ -100,7 +101,7 @@ jobs: - name: Upload digest if: ${{ inputs.push_to_images != '' }} - uses: actions/upload-artifact@v4 + 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 +120,10 @@ jobs: PUSH_TO_IMAGES: ${{ inputs.push_to_images }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: ${{ runner.temp }}/digests # `hashFiles` is used to disambiguate between streaming and non-streaming images @@ -131,25 +132,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 }} @@ -160,11 +161,11 @@ jobs: - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests run: | - echo "$PUSH_TO_IMAGES" | xargs -I{} \ + echo "${PUSH_TO_IMAGES,,}" | xargs -I{} \ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '{}@sha256:%s ' *) - name: Inspect image run: | - echo "$PUSH_TO_IMAGES" | xargs -i{} \ + echo "${PUSH_TO_IMAGES,,}" | xargs -i{} \ docker buildx imagetools inspect {}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index 6ec561d2fb589b..aa3ca56ce4c002 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@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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 8266ff43f3d60c..b07ec8cf7720de 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -9,7 +9,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@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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 @@ -20,13 +57,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.3.') }} + 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.3.') }} + 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 fa28d28f740c45..e28552f40ac2cc 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -22,16 +22,17 @@ on: jobs: security: runs-on: ubuntu-latest + timeout-minutes: 15 env: BUNDLE_ONLY: development steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index c46090c1b565bc..ea5ab4bb7e8115 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -19,9 +19,10 @@ permissions: jobs: check-i18n: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -38,12 +39,11 @@ jobs: run: bin/i18n-tasks check-normalized - name: Check for unused strings - run: bin/i18n-tasks unused + run: bin/i18n-tasks unused -l en - 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 4e6179bc7748db..7e6514673d467c 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -1,31 +1,53 @@ 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 + timeout-minutes: 15 + outputs: + changed: ${{ steps.filter.outputs.src }} + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # 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' + timeout-minutes: 15 + needs: pathcheck + if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 + - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -33,9 +55,10 @@ jobs: run: yarn build-storybook - name: Run Chromatic - uses: chromaui/action@v12 + 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 c864e12d2d8c09..451230021dcc9a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,6 +17,7 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 15 permissions: actions: read contents: read @@ -31,11 +32,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -48,7 +49,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@v3 + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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 +62,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 28890321977479..3b77417ca4495e 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -9,11 +9,11 @@ permissions: jobs: download-translations-stable: runs-on: ubuntu-latest - if: github.repository == 'mastodon/mastodon' + if: github.repository == 'glitch-soc/mastodon' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2 with: config: crowdin-glitch.yml upload_sources: false @@ -51,7 +51,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 1fdd1e08b4fc53..4a5446f99412e8 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2 with: config: crowdin-glitch.yml upload_sources: false @@ -53,7 +53,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@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # 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 d6c542eb361b0e..21979cdeb96e2f 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: crowdin action - uses: crowdin/github-action@v2 + uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2 with: config: crowdin-glitch.yml upload_sources: true diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index c10f350a02ef28..5d06f81ee2a441 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -10,13 +10,14 @@ on: jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript - - name: Check formatting with Prettier + - name: Check formatting run: yarn format:check diff --git a/.github/workflows/haml-lint-problem-matcher.json b/.github/workflows/haml-lint-problem-matcher.json deleted file mode 100644 index 3523ea29515a25..00000000000000 --- a/.github/workflows/haml-lint-problem-matcher.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "haml-lint", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.*):(\\d+)\\s\\[W]\\s(.*):\\s(.*)$", - "file": 1, - "line": 2, - "code": 3, - "message": 4 - } - ] - } - ] -} diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index c1385bf789b0bc..dbd9fc6182bdea 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -9,32 +9,29 @@ on: - 'package.json' - 'yarn.lock' - '.nvmrc' - - '.prettier*' - 'stylelint.config.js' - '**/*.css' - '**/*.scss' - '.github/workflows/lint-css.yml' - - '.github/stylelint-matcher.json' pull_request: paths: - 'package.json' - 'yarn.lock' - '.nvmrc' - - '.prettier*' - 'stylelint.config.js' - '**/*.css' - '**/*.scss' - '.github/workflows/lint-css.yml' - - '.github/stylelint-matcher.json' jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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 499be2010adc99..d212a280bf734e 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -6,7 +6,6 @@ on: - 'main' - 'stable-*' paths: - - '.github/workflows/haml-lint-problem-matcher.json' - '.github/workflows/lint-haml.yml' - '.haml-lint*.yml' - '.rubocop*.yml' @@ -16,7 +15,6 @@ on: pull_request: paths: - - '.github/workflows/haml-lint-problem-matcher.json' - '.github/workflows/lint-haml.yml' - '.haml-lint*.yml' - '.rubocop*.yml' @@ -27,20 +25,20 @@ on: jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 15 env: BUNDLE_ONLY: development steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true - name: Run haml-lint run: | - echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" bin/haml-lint --reporter github diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 86e9af23e7efb1..93cbb2d73d873f 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -10,7 +10,6 @@ on: - 'yarn.lock' - 'tsconfig.json' - '.nvmrc' - - '.prettier*' - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' @@ -24,7 +23,6 @@ on: - 'yarn.lock' - 'tsconfig.json' - '.nvmrc' - - '.prettier*' - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' @@ -35,10 +33,11 @@ on: jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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 87f8aee24e01e9..a308149d61dcf4 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -10,7 +10,6 @@ on: - '.rubocop*.yml' - '.ruby-version' - 'bin/rubocop' - - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -21,7 +20,6 @@ on: - '.rubocop*.yml' - '.ruby-version' - 'bin/rubocop' - - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -29,24 +27,22 @@ on: jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 15 env: BUNDLE_ONLY: development steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true - - name: Set-up RuboCop Problem Matcher - uses: r7kamura/rubocop-problem-matchers-action@v1 - - name: Run rubocop - run: bin/rubocop + run: bin/rubocop --format github - name: Run brakeman if: always() # Run both checks, even if the first failed diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index 945315d52d4bb8..5293480079f8f7 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 0699e6c9ef8193..5a78fd2997d7a3 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -31,10 +31,11 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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 7aab34f0cf4ee6..4d0c4b811a800a 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -25,6 +25,7 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false @@ -72,7 +73,7 @@ jobs: BUNDLE_RETRY: 3 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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 63d317250436ae..e27f11eaf129aa 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -19,6 +19,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: true @@ -32,7 +33,7 @@ jobs: SECRET_KEY_BASE_DUMMY: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -43,7 +44,7 @@ jobs: onlyProduction: 'true' - name: Cache assets from compilation - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | public/assets @@ -65,7 +66,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@v4 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: matrix.mode == 'test' with: path: |- @@ -75,6 +76,7 @@ jobs: test: runs-on: ubuntu-latest + timeout-minutes: 30 needs: - build @@ -124,13 +126,13 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.2' - '3.3' + - '3.4' - '.ruby-version' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -151,7 +153,7 @@ jobs: bin/flatware fan bin/rails db:test:prepare - name: Cache RSpec persistence file - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | tmp/rspec/examples.txt @@ -163,106 +165,20 @@ jobs: rspec-persistence-main rspec-persistence - - run: bin/flatware rspec -r ./spec/flatware_helper.rb + - run: bin/flatware rspec - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 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@v4 - - - uses: actions/download-artifact@v4 - 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 + timeout-minutes: 15 needs: - build @@ -304,14 +220,14 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.2' - '3.3' + - '3.4' - '.ruby-version' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -334,7 +250,7 @@ jobs: - name: Cache Playwright Chromium browser id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.cache/ms-playwright key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -350,14 +266,14 @@ jobs: - run: bin/rspec spec/system --tag streaming --tag js - name: Archive logs - uses: actions/upload-artifact@v4 + 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@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: e2e-screenshots-${{ matrix.ruby-version }} @@ -366,6 +282,7 @@ jobs: test-search: name: Elastic Search integration testing runs-on: ubuntu-latest + timeout-minutes: 15 needs: - build @@ -435,21 +352,21 @@ jobs: fail-fast: false matrix: ruby-version: - - '3.2' - '3.3' + - '3.4' - '.ruby-version' search-image: - - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 + - docker.elastic.co/elasticsearch/elasticsearch:7.17.29 include: - ruby-version: '.ruby-version' - search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 + search-image: docker.elastic.co/elasticsearch/elasticsearch:8.19.2 - ruby-version: '.ruby-version' search-image: opensearchproject/opensearch:2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -469,15 +386,8 @@ jobs: - run: bin/rspec --tag search - name: Archive logs - uses: actions/upload-artifact@v4 + 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@v4 - if: failure() - with: - name: test-search-screenshots - path: tmp/capybara/ diff --git a/.gitignore b/.gitignore index db63bc07f0d003..4727d9ec27f983 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ /public/packs /public/packs-dev /public/packs-test +stats.html .env .env.production node_modules/ diff --git a/.haml-lint.yml b/.haml-lint.yml index 74d243a3ad63f9..4048895806e66a 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -10,6 +10,6 @@ linters: MiddleDot: enabled: true LineLength: - max: 300 + max: 240 # Override default value of 80 inherited from rubocop ViewLength: max: 200 # Override default value of 100 inherited from rubocop diff --git a/.nvmrc b/.nvmrc index 6e77d0a7496300..7858245567393b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.19 +24.16 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000000..1637bf0d0f634f --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,99 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "jsxSingleQuote": true, + "printWidth": 80, + "ignorePatterns": [ + "/tmp", + "/coverage", + "/public/assets", + "/public/emoji", + "/public/packs", + "/public/packs-test", + "/public/system", + "/public/vite*", + + "*.html", + "docker-compose.override.yml", + + // Ignore config YAML files that include ERB/ruby code + "config/email.yml", + + // Vendored CSS + "app/javascript/styles/mastodon/reset.scss", + "app/javascript/flavours/glitch/styles/reset.scss", + + // Automatically generated + "/app/javascript/mastodon/features/emoji/emoji_map.json", + "/app/javascript/mastodon/features/emoji/emoji_data.json", + "AUTHORS.md", + "/app/javascript/mastodon/locales/*.json", + "/config/locales", + ".storybook/static/mockServiceWorker.js", + + // Automatically generated (glitch-soc) + "/app/javascript/flavours/glitch/features/emoji/emoji_map.json", + "/app/javascript/flavours/glitch/features/emoji/emoji_data.json", + "/app/javascript/flavours/glitch/locales/*.json", + "/config/locales-glitch", + + // do not reformat JS files as this will change too many files and cause merge conflicts with open PRs and forks + "app/javascript/**/*.js", + "app/javascript/**/*.jsx", + "streaming/**/*.js" + ], + "experimentalSortPackageJson": false, + "experimentalSortImports": { + "groups": [ + ["builtin"], + ["react"], + ["react-intl"], + ["react-utils"], + ["redux"], + ["external", "type-external"], + ["internal", "type-internal"], + ["mastodon-internals"], + ["parent", "type-parent"], + ["sibling", "type-sibling", "index", "type-index"], + ["side_effect"] + ], + "customGroups": [ + { + "groupName": "react", + "elementNamePattern": [ + "react", + "react-dom", + "react-dom/client", + "prop-types" + ] + }, + { + "groupName": "react-intl", + "elementNamePattern": ["react-intl", "intl-messageformat"] + }, + { + "groupName": "react-utils", + "elementNamePattern": [ + "classnames", + "react-helmet", + "react-router", + "react-router-dom" + ] + }, + { + "groupName": "redux", + "elementNamePattern": [ + "immutable", + "@reduxjs/toolkit", + "react-redux", + "react-immutable-proptypes", + "react-immutable-pure-component" + ] + }, + { + "groupName": "mastodon-internals", + "elementNamePattern": ["mastodon/**", "flavours/glitch/**", "@/**"] + } + ] + } +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 4e3b925f73873e..00000000000000 --- a/.prettierignore +++ /dev/null @@ -1,100 +0,0 @@ -# See https://help.github.com/articles/ignoring-files for more about ignoring files. -# -# If you find yourself ignoring temporary files generated by your text editor -# or operating system, you probably want to add a global ignore instead: -# git config --global core.excludesfile '~/.gitignore_global' - -# Ignore bundler config and downloaded libraries. -/.bundle -/vendor/bundle - -# Ignore the default SQLite database. -/db/*.sqlite3 -/db/*.sqlite3-journal - -# Ignore all logfiles and tempfiles. -.eslintcache -/log/* -!/log/.keep -/tmp -/coverage -.env -.env.production -.env.development -/node_modules/ -/build/ - -# Ignore Vagrant files -.vagrant/ - -# Ignore IDE files -.vscode/ -.idea/ - -# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose -/postgres -/postgres14 -/redis -/elasticsearch - -# Ignore Apple files -.DS_Store - -# Ignore vim files -*~ -*.swp - -# Ignore log files -*.log - -# Ignore Docker option files -docker-compose.override.yml - -# Ignore public -/public/assets -/public/emoji -/public/packs -/public/packs-test -/public/system -/public/vite* - -# Ignore emoji map file -/app/javascript/mastodon/features/emoji/emoji_map.json -/app/javascript/mastodon/features/emoji/emoji_data.json - -# Ignore locale files -/app/javascript/mastodon/locales/*.json -/config/locales - -# Ignore vendored CSS reset -app/javascript/styles/mastodon/reset.scss - -# Ignore Javascript pending https://github.com/mastodon/mastodon/pull/23631 -*.js -*.jsx - -# Ignore HTML till cleaned and included in CI -*.html - -# Ignore the generated AUTHORS.md -AUTHORS.md - -# Process a few selected JS files -!lint-staged.config.js - -# Ignore config YAML files that include ERB/ruby code prettier does not understand -/config/email.yml - -# Ignore glitch-soc emoji map file -/app/javascript/flavours/glitch/features/emoji/emoji_map.json -/app/javascript/flavours/glitch/features/emoji/emoji_data.json - -# Ignore glitch-soc locale files -/app/javascript/flavours/glitch/locales -/config/locales-glitch - -# Ignore glitch-soc vendored CSS reset -app/javascript/flavours/glitch/styles/reset.scss - -# Ignore win95 theme -app/javascript/styles/win95.scss \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 65ec869c338d9d..00000000000000 --- a/.prettierrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - singleQuote: true, - jsxSingleQuote: true -}; diff --git a/.rubocop.yml b/.rubocop.yml index 1bbba515af1add..425709187c35a9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,7 +8,7 @@ AllCops: - lib/mastodon/migration_helpers.rb ExtraDetails: true NewCops: enable - TargetRubyVersion: 3.2 # Oldest supported ruby version + TargetRubyVersion: 3.3 # Oldest supported ruby version inherit_from: - .rubocop/layout.yml diff --git a/.rubocop/layout.yml b/.rubocop/layout.yml index 487879ca2c1134..93966749952bc2 100644 --- a/.rubocop/layout.yml +++ b/.rubocop/layout.yml @@ -4,3 +4,6 @@ Layout/FirstHashElementIndentation: Layout/LineLength: Max: 300 # Default of 120 causes a duplicate entry in generated todo file + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml index bbd172e65606ca..d98c6fc4865c33 100644 --- a/.rubocop/rails.yml +++ b/.rubocop/rails.yml @@ -24,3 +24,6 @@ Rails/RakeEnvironment: Rails/SkipsModelValidations: Enabled: false + +Rails/StrongParametersExpect: + Enabled: false diff --git a/.rubocop/style.yml b/.rubocop/style.yml index f59340d452e871..1d1c9f987975e4 100644 --- a/.rubocop/style.yml +++ b/.rubocop/style.yml @@ -33,6 +33,9 @@ Style/NumericLiterals: AllowedPatterns: - \d{4}_\d{2}_\d{2}_\d{6} +Style/OneClassPerFile: + Enabled: false + Style/PercentLiteralDelimiters: PreferredDelimiters: '%i': () diff --git a/.ruby-version b/.ruby-version index 1cf8253024ccd6..7636e75650d437 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.6 +4.0.5 diff --git a/.simplecov b/.simplecov new file mode 100644 index 00000000000000..1f84c7154753e7 --- /dev/null +++ b/.simplecov @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +SimpleCov.configure do + # During parallel runs, ensure unique names for post-run merge + command_name "job-#{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER'] + + if ENV['CI'] + require 'simplecov-lcov' + formatter SimpleCov::Formatter::LcovFormatter + formatter.config.report_with_single_file = true + else + formatter SimpleCov::Formatter::HTMLFormatter + end + + enable_coverage :branch + + add_filter 'lib/linter' + + add_group 'Libraries', 'lib' + add_group 'Policies', 'app/policies' + add_group 'Presenters', 'app/presenters' + add_group 'Search', 'app/chewy' + add_group 'Serializers', 'app/serializers' + add_group 'Services', 'app/services' + add_group 'Validators', 'app/validators' +end diff --git a/.storybook/main.ts b/.storybook/main.ts index bb69f0c664957c..c8f60c17a26263 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -3,7 +3,18 @@ import { resolve } from 'node:path'; import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { - stories: ['../app/javascript/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + stories: [ + { + directory: '../app/javascript/mastodon', + files: '**/*.stories.@(js|jsx|mjs|ts|tsx)', + titlePrefix: 'Vanilla', + }, + { + directory: '../app/javascript/flavours/glitch', + files: '**/*.stories.@(js|jsx|mjs|ts|tsx)', + titlePrefix: 'Glitch', + }, + ], addons: [ '@storybook/addon-docs', '@storybook/addon-a11y', @@ -27,11 +38,12 @@ 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 // from the Vite config so we need to set it manually. - config.root = resolve(__dirname, '../app/javascript'); + config.root = resolve(import.meta.dirname, '../app/javascript'); return config; }, }; 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 fcba9230308dd7..0461b47326477d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -11,19 +11,26 @@ 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 { IdentityContext } from '@/mastodon/identity_context'; import type { LocaleData } from '@/mastodon/locales'; import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; -// If you want to run the dark theme during development, -// you can change the below to `/application.scss` -import '../app/javascript/styles/mastodon-light.scss'; +import { modes } from './modes'; + +import '../app/javascript/styles/application.scss'; import './styles.css'; -const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { - query: { as: 'json' }, -}); +// Disabling locales in Storybook as it's breaking with Vite 8. +// const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { +// query: { as: 'json' }, +// }); // Initialize MSW initialize({ @@ -34,25 +41,71 @@ const preview: Preview = { // Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], globalTypes: { - locale: { - description: 'Locale for the story', + // locale: { + // description: 'Locale for the story', + // toolbar: { + // title: 'Locale', + // icon: 'globe', + // items: Object.keys(localeFiles).map((path) => + // path.replace('/mastodon/locales/', '').replace('.json', ''), + // ), + // dynamicTitle: true, + // }, + // }, + theme: { + description: 'Theme for the story', toolbar: { - title: 'Locale', - icon: 'globe', - items: Object.keys(localeFiles).map((path) => - path.replace('/mastodon/locales/', '').replace('.json', ''), - ), - dynamicTitle: true, + title: 'Theme', + items: [ + { value: 'light', icon: 'circlehollow' }, + { value: 'dark', icon: 'circle' }, + ], + }, + }, + loggedIn: { + description: 'Whether a user is logged in', + toolbar: { + title: 'Logged in', + icon: 'user', + items: [ + { value: 'true', title: 'logged in' }, + { value: 'false', title: 'logged out' }, + ], }, }, }, initialGlobals: { locale: 'en', + theme: 'light', + loggedIn: 'true', }, decorators: [ - (Story, { parameters, globals }) => { + (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 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( { meta: { @@ -60,7 +113,9 @@ const preview: Preview = { }, }, state as Record, + argsState, ); + const store = configureStore({ reducer, middleware(getDefaultMiddleware) { @@ -74,7 +129,7 @@ const preview: Preview = { ); }, (Story, { globals }) => { - const currentLocale = (globals.locale as string) || 'en'; + const currentLocale = globals.locale || 'en'; const [messages, setMessages] = useState< Record> >({}); @@ -96,15 +151,18 @@ const preview: Preview = { }, [currentLocale, currentLocaleData]); return ( - + ); }, + (Story, { globals }) => { + const theme = globals.theme; + useEffect(() => { + document.body.setAttribute('data-color-scheme', theme); + }, [theme]); + return ; + }, (Story) => ( @@ -120,8 +178,28 @@ const preview: Preview = { /> ), + (Story, { globals }) => { + const signedIn = globals.loggedIn !== 'false'; + return ( + + + + ); + }, + ], + loaders: [ + mswLoader, + importCustomEmojiData, + importLegacyShortcodes, + ({ globals: { locale } }) => importEmojiData(locale), ], - loaders: [mswLoader], parameters: { layout: 'centered', @@ -146,6 +224,13 @@ const preview: Preview = { msw: { handlers: mockHandlers, }, + + chromatic: { + modes: { + dark: modes.darkTheme, + light: modes.lightTheme, + }, + }, }, }; diff --git a/.storybook/static/mockServiceWorker.js b/.storybook/static/mockServiceWorker.js index 15623f1090b9f1..b17fcd650c0ac1 100644 --- a/.storybook/static/mockServiceWorker.js +++ b/.storybook/static/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.11.3' +const PACKAGE_VERSION = '2.12.14' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() @@ -205,6 +205,7 @@ async function resolveMainClient(event) { * @param {FetchEvent} event * @param {Client | undefined} client * @param {string} requestId + * @param {number} requestInterceptedAt * @returns {Promise} */ async function getResponse(event, client, requestId, requestInterceptedAt) { diff --git a/.storybook/storybook-addon-vitest.d.ts b/.storybook/storybook-addon-vitest.d.ts deleted file mode 100644 index 86852faca9f8f4..00000000000000 --- a/.storybook/storybook-addon-vitest.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// The addon package.json incorrectly exports types, so we need to override them here. -// 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'; -} - -export {}; diff --git a/.storybook/storybook.d.ts b/.storybook/storybook.d.ts new file mode 100644 index 00000000000000..4d4391241581b2 --- /dev/null +++ b/.storybook/storybook.d.ts @@ -0,0 +1,26 @@ +// 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 interface Globals { + locale: string; + theme: 'light' | 'dark'; + loggedIn: 'true' | 'false'; + } +} + +export {}; diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts deleted file mode 100644 index a08badd02f85e1..00000000000000 --- a/.storybook/vitest.setup.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; -import { setProjectAnnotations } from '@storybook/react-vite'; - -import * as projectAnnotations from './preview'; - -// This is an important step to apply the right configuration when testing your stories. -// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations -setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); 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 3ad88c51070c5a..ed5127a84ec519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,554 @@ All notable changes to this project will be documented in this file. +## [4.6.0] - 2026-06-17 + +### Added + +- **Add collections** (#37992, #37005, #37049, #37020, #37053, #37110, #37117, #37122, #37154, #37157, #37176, #37192, #37222, #37225, #37254, #37277, #37298, #37322, #37434, #37468, #37514, #37512, #37549, #37556, #37560, #37580, #37591, #37552, #37618, #37643, #37658, #37731, #37678, #37741, #37762, #37790, #37805, #37823, #37837, #37842, #37850, #37848, #37812, #37950, #37898, #37916, #37920, #37927, #37928, #37961, #37967, #37974, #37989, #37986, #38004, #38026, #38027, #38030, #38038, #38065, #38081, #38082, #38096, #38106, #38113, #38124, #38133, #38144, #38153, #38166, #38167, #38169, #38170, #38177, #38193, #38213, #38251, #38255, #38256, #38282, #38298, #38292, #38307, #38306, #38316, #38115, #38329, #38334, #38337, #38351, #38368, #38370, #38356, #38383, #38386, #38385, #38394, #38393, #38399, #38402, #38409, #38414, #38413, #38424, #38425, #38450, #38508, #38528, #38534, #38536, #38540, #38543, #38491, #38586, #38611, #38588, #38612, #38628, #38626, #38630, #38633, #38629, #38638, #38645, #38644, #38636, #38660, #38657, #38688, #38690, #38672, #38698, #38697, #38708, #38712, #38713, #38709, #38719, #38728, #38730, #38732, #38739, #38749, #38751, #38750, #38767, #38769, #38783, #38785, #38959, #38786, #38794, #38776, #38817, #38792, #38822, #38827, #38831, #38830, #38844, #38843, #38852, #38850, #38847, #38865, #38897, #38900, #38919, #38933, #38934, #38935, #38942, #38941, #38954, #38961, #38957, #38962, #38991, #39009, #39062, #39029, #39069, #39020, #39073, #39082, #39096, #39080, #39182, #39143, #39127, #37929, #38029, #39194, #39198, #39210, #39211, #39202, #39214, #39215, #39220, #39234, #39260, #39251, #39361, #39357, #39349, #39287, #39376, #39289, #39342, #38711, #39379, #39282, #39286, #39296, #39047, #39346, #39373, #39372, #39429, and #39457 by @ChaosExAnima, @ClearlyClaire, @Gargron, @arte7, @diondiondion, @mjankowski, @oneiros, and @shleeable) + - Create collections with up to 25 accounts each, then share them with others. You can read more about this feature [on our blog](https://blog.joinmastodon.org/2026/04/designing-collections/). This is based on FEP-7aa9 (Featured Collections) to be interoperable with the wider Fediverse. All the new API methods [are documented here](https://docs.joinmastodon.org/client/collections/). +- **Add email subscriptions** (#38163, #38507, #38502, #38487, #38527, #38582, #38741, #38907, #39162, #39271 by @ClearlyClaire and @Gargron) + - Admins can allow specific roles to enable email subscriptions on their profile, allowing anonymous visitors to subscribe to their posts via email. +- **Add new overview landing page setting** (#39074, #39170, #39163, and #39138 by @Gargron, @diondiondion, and @zunda) + - Admins can choose a new frontpage for anonymous visitors, which combines the about page and most recent posts from local profiles. +- **Add ability to require 2FA for specific roles** (including Everybody) (#37701, #37846, and #38906 by @ClearlyClaire and @mjankowski) +- Add import and export for custom filters (#39085, #39256, #39386 by @arte7) +- Add ability to search email blocks by domain in admin UI (#38923 by @arte7) +- Add new endpoints for profile editing in REST API (#37912, #37934, #37932, #38221, and #38339 by @ClearlyClaire) + - Add `GET /api/v1/profile` and `PATCH /api/v1/profile` to replace the existing `update_credentials` endpoint. See [the documentation](https://docs.joinmastodon.org/methods/profile/) for more information. +- Add `missing_attribution` boolean to preview cards in REST API (#38043 by @ClearlyClaire) + - Documentation: https://docs.joinmastodon.org/entities/PreviewCard/#missing_attribution +- Add `exclude_direct` flag to `/api/v1/accounts/:id/statuses` to exclude direct messages (#37763 by @ClearlyClaire) +- Add `max_note_length` and `max_display_name_length` attributes to `configuration.accounts` in `Instance` entity (#37991 by @ClearlyClaire) +- Add profile field limits to instance entity in REST API (#37535 by @mkljczk) + - This adds attributes `configuration.accounts.max_profile_fields`, `configuration.accounts.profile_field_name_limit` and `configuration.accounts.profile_field_value_limit` to the [`Instance` entity](https://docs.joinmastodon.org/entities/Instance). +- Add `unresolved` flag to `/api/v1/admin/reports` to query both resolved and unresolved reports (#38323 by @mkljczk) +- Add fallback attributes to notifications for new and infrequent notifications in REST API (#38832 and #38860 by @ClearlyClaire) + - This adds a [`supported_types`](https://docs.joinmastodon.org/methods/notifications/#query-parameters-1) parameter to `GET /api/v1/notifications`, `GET /api/v1/notifications/:id`, `GET /api/v2/notifications`, and `GET /api/v2/notifications/:group_key` along with a new `fallback` attribute for notifications and notification groups. +- Add support for posts in vertical languages in web UI (#37204, #38205, and #38797 by @shimon1024) +- Add `Alt` + `PageUp` and `Alt` + `PageDown` hotkeys for list navigation (#39252 and #39427 by @diondiondion) +- Add `g`+`e` keyboard shortcut to access the trending page in web UI (#38014 by @antoinecellerier) +- Add `Cmd`/`Ctrl`+`Enter` for form submissions in more text areas in web UI (#37821 by @diondiondion) +- Add support for quoting by dragging a link into the compose form in web UI (#36859 and #36896 by @ClearlyClaire and @tribela) +- Add `text-autospace` to posts to improve rendering of mixed script posts in web UI (#37694 by @ahxxm) +- Add Taiwanese (Minnan), Lazuri, Mingrelian and Ottoman Turkish to supported locales (#37650, #34923, #37822, #37721, #38648 by @ClearlyClaire and @Yoxem) +- Add ability to filter notifications from bots (#38809 and #39377 by @evanp and @shleeable) +- Add support for `hosts` resolver in request socket DNS lookup (#38699, #38866, and #39030 by @ClearlyClaire and @mjankowski) +- Add support for FEP-2c59 (Webfinger Backlink) (#38239, #38538, and #38639 by @ClearlyClaire and @shleeable) +- Add support for FEP-3b86 (Activity Intents) (#38120 and #38130 by @ClearlyClaire and @Gargron) +- Add support for alt text for profile pictures and headers (#37634, #37641, #38000, #39352 by @ClearlyClaire and @Doxterpepper) +- Add support for multiple keypairs for remote accounts (#38279, #38407, #38419, #38511, #38516, #38515, #38555 and #39235 by @ClearlyClaire) +- Add duration to ActivityPub representation of media attachments (#38061 by @ClearlyClaire) +- Add Stoplight circuit-breaker on Elasticsearch endpoints to better handle some Elasticsearch failures (#39323 and #39375 by @ClearlyClaire and @shleeable) +- Add support for the “require approval” feature for email domain blocks to `tootctl email_domain_blocks` (#34579 and #38107 by @ClearlyClaire and @e-nomem) +- Add `--keep-interacted` flag to `tootctl media remove` to preserve cached media on cleanup (#36200 by @northerner) +- Add systemd service file for prometheus exporter (#35130 by @ThisIsMissEm) + +### Changed + +- **Change design of profiles in web UI** (#37472, #37490, #37479, #37513, #37527, #37550, #37538, #37632, #37627, #37593, #37638, #37626, #37645, #37653, #37683, #37707, #37682, #37742, #37747, #37760, #37761, #37831, #37766, #37811, #37813, #37825, #37854, #37851, #37876, #37885, #37892, #37890, #37907, #37922, #37952, #37958, #37996, #37990, #37994, #38005, #38012, #38040, #38052, #38066, #38083, #38147, #38148, #38152, #38168, #38156, #38175, #38191, #38189, #38235, #38283, #38310, #38309, #38315, #38314, #38365, #38366, #38363, #38346, #38382, #38384, #38400, #38404, #38417, #38426, #38440, #38442, #38443, #38445, #38446, #38451, #38456, #38509, #38510, #38512, #38513, #38517, #38529, #38531, #38535, #38532, #38544, #38549, #38575, #38579, #38580, #38581, #38585, #38584, #38604, #38605, #38606, #38607, #38622, #38616, #38625, #38632, #38640, #38663, #38667, #38646, #38691, #38692, #38766, #38791, #38687, #38826, #38828, #38863, #38845, #38870, #38872, #38932, #38945, #38963, #38964, #39055, #39042, #38893, #39079, #39084, #39160, #39070, #39217, #39309, #39354, #39324, #39387, #39452, #39467 by @ChaosExAnima, @ClearlyClaire, @Coro365, @diondiondion, and @shleeable) + - The profile screen has been entirely redesigned, has new features, and allows you to update your own profile directly without going into the preferences panel. You can read more about it [on our blog](https://blog.joinmastodon.org/2026/03/a-redesign-for-profiles/). +- **Change how #Wrapstodon reports are generated and displayed** (#37033, #37045, #37093, #37055, #37096, #37047, #37103, #37104, #37106, #37109, #37121, #37138, #37134, #37177, #37182, #37169, #37186, #37187, #37188, #37189, #37190, #37193, #37198, #37201, #37203, #37205, #37206, #37207, #37209, #37202, #37216, #37219, #37224, #37226, #37229, #37249, #37251, #37256, #37261, #37269, #37270, #37273, and #37289 by @ChaosExAnima, @ClearlyClaire, @channyeintun, and @diondiondion) + - This finishes up work started in 2024 by completely revamping how Wrapstodon reports are generated and displayed, reducing the amount of data collected and generating reports when active users ask for them. + - Instead of requiring manual generation from a server administrator, this is now offered between the 10th of December and the end of each year if enabled in the server settings. + - The design of the Wrapstodon report has also been fully reworked to be more delightful and easier to share! + - The relevant API endpoints are documented at https://docs.joinmastodon.org/methods/annual_reports/ +- Change limitation to allow posts with both media and a poll to be created (#39203, #39368, #39388 by @ClearlyClaire and @Gargron) +- Change account display name length limit from 30 to 40 characters (#39458 by @mjankowski) +- Change alt text limit for media attachments to 10,000 characters (#39306 by @ClearlyClaire) +- Change pending user notification email to link directly to the pending account (#39206 by @vmstan) +- Changed emoji processing in web UI to make it less resource intensive and more robust (#39077, #39008, #39088, #38892, #38885, #38965, #38854, #38825, #38784, #38541, #37442, #37300, #37306, #37271, #37255, #37284, #37272, #37178, #37084, #37080, #37418, #39167, #39126, #39353, #39378, #39382, #39402, and #39421 by @ChaosExAnima, @ClearlyClaire, @diondiondion, @gomasy, and @Hanage999) +- Change composer textarea to have a limited height to prevent column scrolling (#39268 by @diondiondion) +- Change mentions of “Mastodon gGmbH” to “Mastodon GmbH” (#39261 by @renchap) +- Change the limited profile message to be less misleading (#39231 by @mortie) +- Change images/videos in posts in web UI to not have unlimited height (#36966, #37035, #37136, and #37032 by @diondiondion) +- Change search field and tabs to stick to the top on the search results page in web UI (#38968 by @diondiondion) +- Change “anyone can quote” label to “quotes allowed” in web UI (#37427 by @vmstan) +- Change navigation by `j`/`k` hotkeys to anchor navigated item to top of viewport in web UI (#38036 by @diondiondion) +- Change hotkeys to focus columns to not reset scroll, add hotkey `0` to scroll to top in web UI (#37052 by @diondiondion) +- Change media modal swipe animation in web UI (#36916, #37034, #37323, and #37464 by @ChaosExAnima and @heathdutton) +- Change “Hide”/“Show all” eye icon in thread view in web UI (#22301 by @tribela) +- Change order of onboarding steps (follow people, then fill out profile) in web UI (#38121 by @Gargron) +- Change “Why do you want to join” field on the sign-up page to have a label (#38936 by @diondiondion) +- Change date of birth field on the sign-up page to use locale-specific fields order (#36039 and #36895 by @mjankowski) +- Change how invalid-but-not-expired invites are shown in admin UI (#38736 by @ClearlyClaire) +- Change wording and ordering of media display settings (#38731 by @mjankowski) +- Change wording of server account recommendation setting description (#36771 by @mjankowski) +- Change wording and ordering of account migration warnings (#20387 by @jsoref) +- Change wording of “Automatic post deletion” settings (#37286 by @mjankowski) +- Change wording of language filter settings to clarify they do not impact home/lists (#38490 by @mjankowski) +- Change wording of `tootctl preview_cards remove` command description to clearly state it only removes media (#39348 by @mjankowski) +- Change invitations to only bypass sign-up approval setting when the issuer of the invitation has the `invite_bypass_approval` permission (#38278 by @ClearlyClaire) + - This splits the “Invite Users” permission into a new “Invite Users without review” permission. + - Existing roles will be updated to have the new permission if they have the old one, but default permissions will not include the new `invite_bypass_approval` permission. +- Change followers synchronization mechanism on followers-only posts to be skipped for accounts with 25k followers or more (#37302 by @ClearlyClaire) +- Change "Accept" link on sign-up page to a form to prevent some crawling behavior (#39283 and #39345 by @ClearlyClaire and @mjankowski) +- Change “dark”, “light” and “high contrast” themes to be separate “Color scheme” and “Contrast” settings handled by a single theme (#37095, #37120, #37288, #37459, #37470, #37477, #37519, #37520, #37523, #37524, #37526, #37612, #37824, #37807, #37810, #37819, #37906, and #38261 by @ClearlyClaire, @diondiondion, and @mjankowski) + - Existing settings should be migrated automatically from user settings, and using browser defaults otherwise. + - This also allows third-party theme authors to make use of the same browser defaults and user settings. Learn more about this in [our new Theming docs](https://docs.joinmastodon.org/dev/frontend/theming/). +- Change default theme to use CSS theme tokens (#36861, #36936, #37019, #37054, #37056, #37081, #37105, #37268, #37841, #37843, #38387, #38459, and #38621 by @diondiondion) + - A [guide to using the new tokens](https://docs.joinmastodon.org/dev/frontend/design-tokens/) can be found in our docs. +- Change location blocks in default `nginx.conf` (#19644 and #37866 by @BedrockDigger and @Izorkin) +- Change `proxy_read_timeout` to 120 seconds in default `nginx.conf` (#30599 by @shleeable) +- Change JSON-LD collection handling (#34595 and #37806 by @ClearlyClaire and @sneakers-the-rat) + +### Removed + +- Remove support for EOL Node version 20 (#38926 by @mjankowski) +- Remove support for Ruby 3.2 (#37476 by @mjankowski) +- Remove support for `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI` (#38340 by @ClearlyClaire) +- Remove support for ImageMagick (#37488 by @mjankowski) +- Remove outdated hint for "Use system scrollbar" preference (#39297 by @diondiondion) + +### Fixed + +- Fix accessibility issues in web UI (#37250, #38006, #38033, #38188, #38230, #38252, #38257, #38285, #38293, #38362, #38387, #38459, #38796, #38801, #39098, #39111, #39120, #39129, #39133, #39134, #39144, #39145, #39149, #39164, #39165, #39169, #39181, #39335, #39305, #39331, #39356, #39350, #39358, #39360, #39325, #39270, #39439, #39400, and #39408 by @ChaosExAnima and @diondiondion) +- Fix report modal heading being impossible to translate properly in some languages (#39457 by @diondiondion) +- Fix being unable to edit an attachment twice without submitting (#39453 by @ClearlyClaire) +- Fix error with audio player in Safari Lockdown Mode (#39397 by @Federicorao) +- Fix tiny checkboxes and radio buttons in Safari (#39332 by @diondiondion) +- Fix handling of offset in timezone list in settings (#39334 by @mjankowski) +- Fix being unable to unmark media as sensitive when "always mark media as sensitive" is enabled in web UI (#39339 by @matrix07012) +- Fix display of sensitive media cards in web UI according to settings (#39366 by @nshki) +- Fix some inputs incorrectly having resize handles in Firefox (#39274 by @diondiondion) +- Fix processing some link previews where text is language-tagged (#39190 by @zunda) +- Fix error when “New trends” email is sent at the same time trends are recomputed (#39122 by @arte7) +- Fix hovercard not showing in compose column (#39430 by @diondiondion) +- Fix hover card opening even when not preceded by mouse movement in web UI (#39166, #39381 by @diondiondion) +- Fix [ominous](https://mastodon.social/@mcc/116404362104299129) "Moments remaining" timestamp in web UI (#38488 and #38689 by @ChaosExAnima and @MitarashiDango) +- Fix filters not being applied to search results in web UI (#36346 by @ClearlyClaire) +- Fix error when visiting non-public hashtag timelines (#36961 by @diondiondion) +- Fix duplicate favourite/boost counters in some languages (#36844 by @ChaosExAnima) +- Fix unblocking domain from blocked domains column not updating the list in web UI (#38882 by @tribela) +- Fix "change thumbnail" button being visible when it shouldn't in web UI (#38467 by @dpbento) +- Fix profile dropdown menu sometimes ending with a separator in web UI (#38481 by @mkljczk) +- Fix short numbers rounding up instead of truncating in web UI (#38114 by @serranodfm) +- Fix directory showing load more button when no more profiles exist in web UI (#37465 by @heathdutton) +- Fix focus restoration after closing some modals in web UI (#37424 by @MegaManSec) +- Fix video modals being pushed down on mobile in web UI (#37421 by @ChaosExAnima) +- Fix outer page margins when viewport width equals content width in web UI (#36733 by @diondiondion) +- Fix announcement margin when in advanced web UI (#36714 by @ChaosExAnima) +- Fix navigation overflow issue in advanced web UI (#39178 by @diondiondion) +- Fix stale merging stale account from cached instance API response in web UI (#37666 by @ChaosExAnima) +- Fix HTML `lang` attribute being stripped out of remote posts (#39114 by @artemist) +- Fix remote posts with large media descriptions being rejected (#39135 by @ClearlyClaire) +- Fix some occurrence of PostgreSQL log pollution when processing new hashtags (#35792 by @oelison) +- Fix blocked domains not being removed from the Instance search index (#39109 by @shleeable) +- Fix Elasticsearch connections not being cleaned up properly in Sidekiq middleware (#39359 by @ClearlyClaire) +- Fix replica database not being used when `REPLICA_DB_HOST` is used but neither `REPLICA_DB_NAME` nor `REPLICA_DATABASE_URL` (#37240 by @smiba) +- Fix remote media attachment thumbnails not being stored in the `cache/` directory (#36911 by @shugo) +- Fix race condition when processing posts twice with the same idempotency key (#37879 by @ClearlyClaire) +- Fix `expire_at` instead of `expires_at` in muted words CSV exports (#39304 by @arte7) +- Fix various missing translation strings (#37671, #37838, #37078, #37371, #37827, #39328 by @ClearlyClaire, @mjankowski, and @valtlai) +- Fix last post time for remote accounts not being accurately tracked (#37619 by @ClearlyClaire) +- Fix filtering of mentions from filtered-on-their-origin-server accounts (#37583 by @ClearlyClaire) +- Fix irrelevant remote accounts being passed through to local fan-out worker (#37589 by @ClearlyClaire) +- Fix required field markers being displayed on fields that cannot be empty anyway in settings (#37291 by @diondiondion) +- Fix thumbnails for links from The Guardian (and possibly other CDNs that check URL hashes) not showing up (#36139 by @phocks) +- Fix `mastodon-async-refresh` response header not being exposed through CORS (#38914 by @mkljczk) +- Fix FASP availability being incorrectly updated (#38818 by @oneiros) +- Fix use of deprecated `vsync` FFmpeg option, using `fps_mode` instead (FFmpeg >= 5.1 now required) (#38198 by @mjankowski) +- Fix unnecessary downcasing of some words in admin UI (#37364 by @ClearlyClaire) +- Fix delivery worker counting unsalvageable HTTP errors as successes (#37235 by @shleeable) +- Fix streaming heartbeat comment not being its own event (#37389 by @ClearlyClaire) +- Fix posts with edited out media attachments being returned in `GET /api/v1/accounts/:id/statuses?only_media=true` (#37363 by @ClearlyClaire) +- Fix wrong media attachment URLs being returned from `DELETE /api/v1/statuses/:id` (#35880 by @dbarabashh) +- Fix hashtag matching by replacing negative look-behind with positive look-behind (#37684 and #38212 by @ClearlyClaire) +- Fix discovery of ActivityPub representation from HTML tags in presence of a non-ActivityPub alternate Link header (#37439 by @shleeable) +- Fix Webfinger endpoint not handling new ActivityPub ID scheme (#38391 by @ClearlyClaire) +- Fix error when admin-selected theme does not exist by falling back to `default` theme (#38703 by @shleeable) +- Fix wrong endonyms for Divehi and Latvian in languages list (#36254 and #36876 by @cuu508 and @shimon1024) +- Fix `Accept` headers when fetching ActivityPub resources not including JSON-LD profile (#30354 by @TheOneric) +- Fix wrong hover indicators on unclickable items in admin UI (#38782 by @diondiondion) +- Fix streaming server using deprecated `url.parse` instead of WHATWG URL API (#36973 by @Exagone313) + +## [4.5.11] - 2026-06-03 + +### Security + +- Fix allowed attribution domains spoofing ([GHSA-rwcw-vq68-g34p](https://github.com/mastodon/mastodon/security/advisories/GHSA-rwcw-vq68-g34p)) +- Fix uncaught exception in message sanitization causing Denial of Service ([GHSA-qrgq-9fx2-vf2r](https://github.com/mastodon/mastodon/security/advisories/GHSA-qrgq-9fx2-vf2r)) +- Update dependencies + +### Fixed + +- Fix remote statuses with large media descriptions being rejected (#39135 by @ClearlyClaire) + +## [4.5.10] - 2026-05-20 + +### Security + +- Fix SSRF protection bypass ([GHSA-crr4-7rm4-8gpw](https://github.com/mastodon/mastodon/security/advisories/GHSA-crr4-7rm4-8gpw), [GHSA-xx55-4rrg-8xg6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xx55-4rrg-8xg6)) +- Fix Linked-Data Signature bypass through JSON-LD graph restructuring features ([GHSA-53m7-2wrh-q839](https://github.com/mastodon/mastodon/security/advisories/GHSA-53m7-2wrh-q839), [GHSA-chgx-jx3p-rf73](https://github.com/mastodon/mastodon/security/advisories/GHSA-chgx-jx3p-rf73)) +- Updated dependencies + +### Fixed + +- Fix type of `interactingObject`, `interactionTarget` and add missing `QuoteAuthorization` (#38940 by @ClearlyClaire) + +### Removed + +- Remove unused devise strategies (#38795 by @ClearlyClaire) + +## [4.5.9] - 2026-04-15 + +### Security + +- Insufficient verification of email addresses ([GHSA-5r37-qpwq-2jhh](https://github.com/mastodon/mastodon/security/advisories/GHSA-5r37-qpwq-2jhh)) +- Updated dependencies + +### Added + +- Add trademark warning to `mastodon:setup` task (#38548 by @ClearlyClaire) + +### Fixed + +- Fix definition for `quote` in JSON-LD context (#38686 by @ClearlyClaire) +- Fix being unable to disable sound for quote update notification (#38537 by @ClearlyClaire) +- Fix being able to quote someone you blocked (#38608 by @ClearlyClaire) + +## [4.5.8] - 2026-03-24 + +### Security + +- Fix insufficient checks on quote authorizations ([GHSA-q4g8-82c5-9h33](https://github.com/mastodon/mastodon/security/advisories/GHSA-q4g8-82c5-9h33)) +- Fix open redirect in legacy path handler ([GHSA-xqw8-4j56-5hj6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xqw8-4j56-5hj6)) +- Updated dependencies + +### Added + +- Add for searching already-known private GtS posts (#38057 by @ClearlyClaire) + +### Changed + +- Change media description length limit for remote media attachments from 1500 to 10000 characters (#37921 by @ClearlyClaire) +- Change HTTP signatures to skip the `Accept` header (#38132 by @ClearlyClaire) +- Change numeric AP endpoints to redirect to short account URLs when HTML is requested (#38056 by @ClearlyClaire) + +### Fixed + +- Fix some model definitions in `tootctl maintenance fix-duplicates` (#38214 by @ClearlyClaire) +- Fix overly strict checks for current username on account migration page (#38183 by @mjankowski) +- Fix OpenStack Swift Keystone token rate limiting (#38145 by @hugogameiro) +- Fix poll expiration notification being re-triggered on implicit updates (#38078 by @ClearlyClaire) +- Fix incorrect translation string in webauthn mailers (#38062 by @mjankowski) +- Fix “Unblock” and “Unmute” actions being disabled when blocked (#38075 by @ClearlyClaire) +- Fix username availability check being wrongly applied on race conditions (#37975 by @ClearlyClaire) +- Fix hover card unintentionally being shown in some cases (#38039 and #38112 by @diondiondion) +- Fix existing posts not being removed from lists when a list member is unfollowed (#38048 by @ClearlyClaire) + +## [4.5.7] - 2026-02-24 + +### Security + +- Reject unconfirmed FASPs (#37926 by @oneiros, [GHSA-qgmm-vr4c-ggjg](https://github.com/mastodon/mastodon/security/advisories/GHSA-qgmm-vr4c-ggjg)) +- Re-use custom socket class for FASP requests (#37925 by @oneiros, [GHSA-46w6-g98f-wxqm](https://github.com/mastodon/mastodon/security/advisories/GHSA-46w6-g98f-wxqm)) + +### Added + +- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski) + +### Fixed + +- Fix emoji data not being properly cached (#37858 by @ChaosExAnima) +- Fix delete & redraft of pending posts (#37839 by @ClearlyClaire) +- Fix processing separate key documents without the ActivityStreams context (#37826 by @ClearlyClaire) +- Fix custom emojis not being purged on domain suspension (#37808 by @ClearlyClaire) +- Fix users without special permissions being able to stream disabled timelines (#37791 by @ClearlyClaire) +- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire) + +## [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 + +- Change private quote education modal to not show up on self-quotes (#36926 by @ClearlyClaire) + +### Fixed + +- Fix missing fallback link in CW-only quote posts (#36963 by @ClearlyClaire) +- Fix statuses without text being hidden while loading (#36962 by @ClearlyClaire) +- Fix `g` + `h` keyboard shortcut not working when a post is focused (#36935 by @diondiondion) +- Fix quoting overwriting current content warning (#36934 by @ClearlyClaire) +- Fix scroll-to-status in threaded view being unreliable (#36927 by @ClearlyClaire) +- Fix path resolution for emoji worker (#36897 by @ChaosExAnima) +- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo) +- Fix cross-origin handling of CSS modules (#36890 by @ClearlyClaire) +- Fix error with remote tags including percent signs (#36886 and #36925 by @ChaosExAnima and @ClearlyClaire) +- Fix bogus quote approval policy not always being replaced correctly (#36885 by @ClearlyClaire) +- Fix hashtag completion not being inserted correctly (#36884 by @ClearlyClaire) +- Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action (#36870 by @diondiondion) + +## [4.5.1] - 2025-11-13 + +### Fixed + +- Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866 by @diondiondion) +- Fix posts coming from public/hashtag streaming being marked as unquotable (#36860 and #36869 by @ClearlyClaire) +- Fix old previously-undiscovered posts being treated as new when receiving an `Update` (#36848 by @ClearlyClaire) +- Fix blank screen in browsers that don't support `Intl.DisplayNames` (#36847 by @diondiondion) +- Fix filters not being applied to quotes in detailed view (#36843 by @ClearlyClaire) +- Fix scroll shift caused by fetch-all-replies alerts (#36807 by @diondiondion) +- Fix dropdown menu not focusing first item when opened via keyboard (#36804 by @diondiondion) +- Fix assets build issue on arch64 (#36781 by @ClearlyClaire) +- Fix `/api/v1/statuses/:id/context` sometimes returing `Mastodon-Async-Refresh` without `result_count` (#36779 by @ClearlyClaire) +- Fix prepared quote not being discarded with contents when replying (#36778 by @ClearlyClaire) + +## [4.5.0] - 2025-11-06 + +### Added + +- **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, #36461, #36516, #36528, #36549, #36550, #36559, #36693, #36704, #36690, #36689, #36696, #36721, #36695 and #36736 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\ + This includes a revamp of the composer interface.\ + See https://blog.joinmastodon.org/2025/09/introducing-quote-posts/ for a user-centric overview of the feature, and https://docs.joinmastodon.org/client/quotes/ for API documentation. +- **Add support for fetching and refreshing replies to the web UI** (#35210, #35496, #35575, #35500, #35577, #35602, #35603, #35654, #36141, #36237, #36172, #36256, #36271, #36334, #36382, #36239, #36484, #36481, #36583, #36627 and #36547 by @ClearlyClaire, @diondiondion, @Gargron and @renchap) +- **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron) +- Add ability to individually disable local or remote feeds for visitors or logged-in users `disabled` value to server setting for live and topic feeds, as well as user permission to bypass that (#36338, #36467, #36497, #36563, #36577, #36585, #36607 and #36703 by @ClearlyClaire)\ + This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds.\ + The setting for local topic feeds has 2 values: `public` and `authenticated`. Every other setting has 3 values: `public`, `authenticated`, `disabled`.\ + When `disabled`, users with the “View live and topic feeds” will still be able to view them. +- Add support for displaying of quote posts in Moderator UI (#35964 by @ThisIsMissEm) +- Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm) +- Add a new server setting to choose the server landing page (#36588 and #36602 by @ClearlyClaire and @renchap) +- Add support for `Update` activities on converted object types (#36322 by @ClearlyClaire) +- Add support for dynamic viewport height (#36272 by @e1berd) +- Add support for numeric-based URIs for new local accounts (#32724, #36304, #36316, and #36365 by @ClearlyClaire) +- Add default visualizer for audio upload without poster (#36734 by @ChaosExAnima) +- Add Traditional Mongolian to posting languages (#36196 by @shimon1024) +- Add example post with manual quote approval policy to `dev:populate_sample_data` (#36099 by @ClearlyClaire) +- Add server-side support for handling posts with a quote policy allowing followers to quote (#36093 and #36127 by @ClearlyClaire) +- Add schema.org markup to SEO-enabled posts (#36075 by @Gargron) +- Add migration to fill unset default quote policy based on default post privacy (#36041 by @ClearlyClaire) +- Add “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion) +- Added emoji from Twemoji v16 (#36501 and #36530 by @ChaosExAnima) +- Add feature to select custom emoji rendering (#35229, #35282, #35253, #35424, #35473, #35483, #35505, #35568, #35605, #35659, #35664, #35739, #35985, #36051, #36071, #36137, #36165, #36248, #36262, #36275, #36293, #36341, #36342, #36366, #36377, #36378, #36385, #36393, #36397, #36403, #36413, #36410, #36454, #36402, #36503, #36502, #36532, #36603, #36409, #36638 and #36750 by @ChaosExAnima, @ClearlyClaire and @braddunbar)\ + This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places. +- Add support for exposing conversation context for new public conversations according to FEP-7888 (#35959 and #36064 by @ClearlyClaire and @jesseplusplus) +- Add digest re-check before removing followers in synchronization mechanism (#34273 by @ClearlyClaire) +- Add support for displaying Valkey version on admin dashboard (#35785 by @ykzts) +- Add delivery failure tracking and handling to FASP jobs (#35625, #35628, and #35723 by @oneiros) +- Add example of quote post with a preview card to development sample data (#35616 by @ClearlyClaire) +- Add second set of blocked text that applies to accounts regardless of account age for spam-blocking (#35563 by @ClearlyClaire) + +### Changed + +- Change confirmation dialogs for follow button actions “unfollow”, “unblock”, and “withdraw request” (#36289 by @diondiondion) +- Change “Follow” button labels (#36264 by @diondiondion) +- Change appearance settings to introduce new Advanced settings section (#36496 and #36506 by @diondiondion) +- Change display of blocked and muted quoted users (#36619 by @ClearlyClaire)\ + This adds `blocked_account`, `blocked_domain` and `muted_account` values to the `state` attribute of `Quote` and `ShallowQuote` REST API entities. +- Change submitting an empty post to show an error rather than failing silently (#36650 by @diondiondion) +- Change "Privacy and reach" settings from "Public profile" to their own top-level category (#27294 by @ChaelCodes) +- Change number of times quote verification is retried to better deal with temporary failures (#36698 by @ClearlyClaire) +- Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm) +- Change styling of column banners (#36531 by @ClearlyClaire) +- Change recommended Node version to 24 (LTS) (#36539 by @renchap) +- Change min. characters required for logged-out account search from 5 to 3 (#36487 by @Gargron) +- Change browser target to Vite legacy plugin defaults (#36611 by @larouxn) +- Change index on `follows` table to improve performance of some queries (#36374 by @ClearlyClaire) +- Change links to accounts in settings and moderation views to link to local view unless account is suspended (#36340 by @diondiondion) +- Change redirection for denied registration from web app to sign-in page with error message (#36384 by @ClearlyClaire) +- Change support for RFC9421 HTTP signatures to be enabled unconditionally (#36610 by @oneiros) +- Change wording and design of interaction dialog to simplify it (#36124 by @diondiondion) +- Change dropdown menus to allow disabled items to be focused (#36078 by @diondiondion) +- Change modal background colours in light mode (#36069 by @diondiondion) +- Change “Posting defaults” settings page to enforce `nobody` quote policy for `private` default visibility (#36040 by @ClearlyClaire) +- Change description of “Quiet public” (#36032 by @ClearlyClaire) +- Change “Boost with original visibility” to “Share again with your followers” (#36035 by @ClearlyClaire) +- Change handling of push subscriptions to automatically delete invalid ones on delivery (#35987 by @ThisIsMissEm) +- Change design of quote posts in web UI (#35584 and #35834 by @Gargron) +- Change auditable accounts to be sorted by username in admin action logs interface (#35272 by @breadtk) +- Change order of translation restoration and service credit on post card (#33619 by @colindean) +- Change position of ‘add more’ to be inside table toolbar on reports (#35963 by @ThisIsMissEm) +- Change docker-compose.yml sidekiq health check to work for both 4.4 and 4.5 (#36498 by @ClearlyClaire) + +### Fixed + +- Fix relationship not being fetched to evaluate whether to show a quote post (#36517 by @ClearlyClaire) +- Fix rendering of poll options in status history modal (#35633 by @ThisIsMissEm) +- Fix “mute” button being displayed to unauthenticated visitors in hashtag dropdown (#36353 by @mkljczk) +- Fix initially selected language in Rules panel, hide selector when no alternative translations exist (#36672 by @diondiondion) +- Fix URL comparison for mentions in case of empty path (#36613 and #36626 by @ClearlyClaire) +- Fix hashtags not being picked up when full-width hash sign is used (#36103 and #36625 by @ClearlyClaire and @Gargron) +- Fix layout of severed relationships when purged events are listed (#36593 by @mejofi) +- Fix Skeleton placeholders being animated when setting to reduce animations is enabled (#36716 by @ClearlyClaire) +- Fix vacuum tasks being interrupted by a single batch failure (#36606 by @Gargron) +- Fix handling of unreachable network error for search services (#36587 by @mjankowski) +- Fix bookmarks export when a bookmarked status is soft-deleted (#36576 by @ClearlyClaire) +- Fix text overflow alignment for long author names in News (#36562 by @diondiondion) +- Fix discovery preamble missing word in admin settings (#36560 by @belatedly) +- Fix overflow handling of `.more-from-author` (#36310 by @edent) +- Fix unfortunate action button wrapping in admin area (#36247 by @diondiondion) +- Fix translate button width in Safari (#36164 and #36216 by @diondiondion) +- Fix login page linking to other pages within OAuth authorization flow (#36115 by @Gargron) +- Fix stale search results being displayed in Web UI while new query is in progress (#36053 by @ChaosExAnima) +- Fix YouTube iframe not being able to start at a defined time (#26584 by @BrunoViveiros) +- Fix banned text being able to be circumvented via unicode (#35978 by @Gargron) +- Fix batch table toolbar displaying under status media (#35962 by @ThisIsMissEm) +- Fix incorrect RSS feed MIME type in gzip_types directive (#35562 by @iioflow) +- Fix 404 error after deleting status from detail view (#35800) (#35881 by @crafkaz) +- Fix feeds keyboard navigation issues (#35853, #35864, and #36267 by @braddunbar and @diondiondion) +- Fix layout shift caused by “Who to follow” widget (#35861 by @diondiondion) +- Fix Vagrantfile (#35765 by @ClearlyClaire) +- Fix reply indicator displaying wrong avatar in rare cases (#35756 by @ClearlyClaire) +- Fix `Chewy::UndefinedUpdateStrategy` in `dev:populate_sample_data` task when Elasticsearch is enabled (#35615 by @ClearlyClaire) +- Fix unnecessary account note addition for already-muted moved-to users (#35566 by @mjankowski) +- Fix seeded admin user creation failing on specific configurations (#35565 by @oneiros) +- Fix media modal images in Web UI having redundant `title` attribute (#35468 by @mayank99) +- Fix inconsistent default privacy post setting when unset in settings (#35422 by @oneiros) +- Fix glitchy status keyboard navigation (#35455 and #35504 by @diondiondion) +- Fix post being submitted when pressing “Enter” in the CW field (#35445 by @diondiondion) + +### Removed + +- Remove support for PostgreSQL 13 (#36540 by @renchap) + +## [4.4.8] - 2025-10-21 + +### Security + +- Fix quote control bypass ([GHSA-8h43-rcqj-wpc6](https://github.com/mastodon/mastodon/security/advisories/GHSA-8h43-rcqj-wpc6)) + +## [4.4.7] - 2025-10-15 + +### Fixed + +- Fix forwarder being called with `nil` status when quote post is soft-deleted (#36463 by @ClearlyClaire) +- Fix moderation warning e-mails that include posts (#36462 by @ClearlyClaire) +- Fix allow_referrer_origin typo (#36460 by @ShadowJonathan) + +## [4.4.6] - 2025-10-13 + +### Security + +- Update dependencies `rack` and `uri` +- Fix streaming server connection not being closed on user suspension (by @ThisIsMissEm, [GHSA-r2fh-jr9c-9pxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-r2fh-jr9c-9pxh)) +- Fix password change through admin CLI not invalidating existing sessions and access tokens (by @ThisIsMissEm, [GHSA-f3q3-rmf7-9655](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q3-rmf7-9655)) +- Fix streaming server allowing access to public timelines even without the `read` or `read:statuses` OAuth scopes (by @ThisIsMissEm, [GHSA-7gwh-mw97-qjgp](https://github.com/mastodon/mastodon/security/advisories/GHSA-7gwh-mw97-qjgp)) + +### Added + +- Add support for processing quotes of deleted posts signaled through a `Tombstone` (#36381 by @ClearlyClaire) + +### Fixed + +- Fix quote post state sometimes not being updated through streaming server (#36408 by @ClearlyClaire) +- Fix inconsistent “pending tags” count on admin dashboard (#36404 by @mjankowski) +- Fix JSON payload being potentially mutated when processing interaction policies (#36392 by @ClearlyClaire) +- Fix quotes not being displayed in email notifications (#36379 by @diondiondion) +- Fix redirect to external object when URL is missing or malformed (#36347 by @ClearlyClaire) +- Fix quotes not being displayed in the featured carousel (#36335 by @diondiondion) + +## [4.4.5] - 2025-09-23 + +### Security + +- Update dependencies + +### Added + +- Add support for `has:quote` in search (#36217 by @ClearlyClaire) + +### Changed + +- Change quoted posts from silenced accounts to use a click-through rather than being hidden (#36166 and #36167 by @ClearlyClaire) + +### Fixed + +- Fix processing of out-of-order `Update` as implicit updates (#36190 by @ClearlyClaire) +- Fix getting `Create` and `Update` out of order (#36176 by @ClearlyClaire) +- Fix quotes with Content Warnings but no text being shown without Content Warnings (#36150 by @ClearlyClaire) + ## [4.4.4] - 2025-09-16 ### Security @@ -371,1716 +919,4 @@ All notable changes to this project will be documented in this file. - Fix use of deprecated `execCommand` for copying text by using the `clipboard` API (#32598 by @renchap) - Fix some translation strings not being properly pluralized (#27094 by @gunchleoc) -## [4.3.8] - 2025-05-06 - -### Security - -- Update dependencies -- Check scheme on account, profile, and media URLs ([GHSA-x2rc-v5wx-g3m5](https://github.com/mastodon/mastodon/security/advisories/GHSA-x2rc-v5wx-g3m5)) - -### Added - -- Add warning for REDIS_NAMESPACE deprecation at startup (#34581 by @ClearlyClaire) -- Add built-in context for interaction policies (#34574 by @ClearlyClaire) - -### Changed - -- Change activity distribution error handling to skip retrying for deleted accounts (#33617 by @ClearlyClaire) - -### Removed - -- Remove double-query for signed query strings (#34610 by @ClearlyClaire) - -### Fixed - -- Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549 by @ClearlyClaire) -- Fix sign-up e-mail confirmation page reloading on error or redirect (#34548 by @ClearlyClaire) - -## [4.3.7] - 2025-04-02 - -### Added - -- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire) -- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire) - -### Changed - -- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire) -- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski) -- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire) - -### Fixed - -- Fix static version of animated PNG emojis not being properly extracted (#34337 by @ClearlyClaire) -- Fix filters not applying in detailed view, favourites and bookmarks (#34259 and #34260 by @ClearlyClaire) -- Fix handling of malformed/unusual HTML (#34201 by @ClearlyClaire) -- Fix `CacheBuster` being queued for missing media attachments (#34253 by @ClearlyClaire) -- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire) -- Fix streaming server refusing unix socket path in `DATABASE_URL` (#34091 by @ClearlyClaire) -- Fix “x” hotkey not working on boosted filtered posts (#33758 by @ClearlyClaire) - -## [4.3.6] - 2025-03-13 - -### Security - -- Update dependency `omniauth-saml` -- Update dependency `rack` - -### Fixed - -- Fix Stoplight errors when using `REDIS_NAMESPACE` (#34126 by @ClearlyClaire) - -## [4.3.5] - 2025-03-10 - -### Changed - -- Change hashtag suggestion to prefer personal history capitalization (#34070 by @ClearlyClaire) - -### Fixed - -- Fix processing errors for some HEIF images from iOS 18 (#34086 by @renchap) -- Fix streaming server not filtering unknown-language posts from public timelines (#33774 by @ClearlyClaire) -- Fix preview cards under Content Warnings not being shown in detailed statuses (#34068 by @ClearlyClaire) -- Fix username and display name being hidden on narrow screens in moderation interface (#33064 by @ClearlyClaire) - -## [4.3.4] - 2025-02-27 - -### Security - -- Update dependencies -- Change HTML sanitization to remove unusable and unused `embed` tag (#34021 by @ClearlyClaire, [GHSA-mq2m-hr29-8gqf](https://github.com/mastodon/mastodon/security/advisories/GHSA-mq2m-hr29-8gqf)) -- Fix rate-limit on sign-up email verification ([GHSA-v39f-c9jj-8w7h](https://github.com/mastodon/mastodon/security/advisories/GHSA-v39f-c9jj-8w7h)) -- Fix improper disclosure of domain blocks to unverified users ([GHSA-94h4-fj37-c825](https://github.com/mastodon/mastodon/security/advisories/GHSA-94h4-fj37-c825)) - -### Changed - -- Change preview cards to be shown when Content Warnings are expanded (#33827 by @ClearlyClaire) -- Change warnings against changing encryption secrets to be even more noticeable (#33631 by @ClearlyClaire) -- Change `mastodon:setup` to prevent overwriting already-configured servers (#33603, #33616, and #33684 by @ClearlyClaire and @mjankowski) -- Change notifications from moderators to not be filtered (#32974 and #33654 by @ClearlyClaire and @mjankowski) - -### Fixed - -- Fix `GET /api/v2/notifications/:id` and `POST /api/v2/notifications/:id/dismiss` for ungrouped notifications (#33990 by @ClearlyClaire) -- Fix issue with some versions of libvips on some systems (#33853 by @kleisauke) -- Fix handling of duplicate mentions in incoming status `Update` (#33911 by @ClearlyClaire) -- Fix inefficiencies in timeline generation (#33839 and #33842 by @ClearlyClaire) -- Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818 by @ClearlyClaire) -- Fix `tootctl feeds build` not building list timelines (#33783 by @ClearlyClaire) -- Fix flaky test in `/api/v2/notifications` tests (#33773 by @ClearlyClaire) -- Fix incorrect signature after HTTP redirect (#33757 and #33769 by @ClearlyClaire) -- Fix polls not being validated on edition (#33755 by @ClearlyClaire) -- Fix media preview height in compose form when 3 or more images are attached (#33571 by @ClearlyClaire) -- Fix preview card sizing in “Author attribution” in profile settings (#33482 by @ClearlyClaire) -- Fix processing of incoming notifications for unfilterable types (#33429 by @ClearlyClaire) -- Fix featured tags for remote accounts not being kept up to date (#33372, #33406, and #33425 by @ClearlyClaire and @mjankowski) -- Fix notification polling showing a loading bar in web UI (#32960 by @Gargron) -- Fix accounts table long display name (#29316 by @WebCoder49) -- Fix exclusive lists interfering with notifications (#28162 by @ShadowJonathan) - -## [4.3.3] - 2025-01-16 - -### Security - -- Fix insufficient validation of account URIs ([GHSA-5wxh-3p65-r4g6](https://github.com/mastodon/mastodon/security/advisories/GHSA-5wxh-3p65-r4g6)) -- Update dependencies - -### Fixed - -- Fix `libyaml` missing from `Dockerfile` build stage (#33591 by @vmstan) -- Fix incorrect notification settings migration for non-followers (#33348 by @ClearlyClaire) -- Fix down clause for notification policy v2 migrations (#33340 by @jesseplusplus) -- Fix error decrementing status count when `FeaturedTags#last_status_at` is `nil` (#33320 by @ClearlyClaire) -- Fix last paginated notification group only including data on a single notification (#33271 by @ClearlyClaire) -- Fix processing of mentions for post edits with an existing corresponding silent mention (#33227 by @ClearlyClaire) -- Fix deletion of unconfirmed users with Webauthn set (#33186 by @ClearlyClaire) -- Fix empty authors preview card serialization (#33151, #33466 by @mjankowski and @ClearlyClaire) - -## [4.3.2] - 2024-12-03 - -### Added - -- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire) -- Add error message when user tries to follow their own account (#31910 by @lenikadali) -- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm) - -### Changed - -- Change design of Content Warnings and filters (#32543 by @ClearlyClaire) - -### Fixed - -- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire) -- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer) -- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire) -- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire) -- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire) -- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron) -- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire) -- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire) -- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire) -- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire) -- Fix titles being escaped twice (#32889 by @ClearlyClaire) -- Fix list creation limit check (#32869 by @ClearlyClaire) -- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski) -- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron) -- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire) -- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire) -- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire) -- Fix embed modal layout on mobile (#32641 by @DismalShadowX) -- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro) -- Fix blocks not being applied on link timeline (#32625 by @tribela) -- Fix follow counters being incorrectly changed (#32622 by @oneiros) -- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond) -- Fix tl language native name (#32606 by @seav) - -### Security - -- Update dependencies - -## [4.3.1] - 2024-10-21 - -### Added - -- Add more explicit explanations about author attribution and `fediverse:creator` (#32383 by @ClearlyClaire) -- Add ability to group follow notifications in WebUI, can be disabled in the column settings (#32520 by @renchap) -- Add back a 6 hours mute duration option (#32522 by @renchap) -- Add note about not changing ActiveRecord encryption secrets once they are set (#32413, #32476, #32512, and #32537 by @ClearlyClaire and @mjankowski) - -### Changed - -- Change translation feature to translate to selected regional variant (e.g. pt-BR) if available (#32428 by @c960657) - -### Removed - -- Remove ability to get embed code for remote posts (#32578 by @ClearlyClaire)\ - Getting the embed code is only reliable for local posts.\ - It never worked for non-Mastodon servers, and stopped working correctly with the changes made in 4.3.0.\ - We have therefore decided to remove the menu entry while we investigate solutions. - -### Fixed - -- Fix follow recommendation moderation page default language when using regional variant (#32580 by @ClearlyClaire) -- Fix column-settings spacing in local timeline in advanced view (#32567 by @lindwurm) -- Fix broken i18n in text welcome mailer tags area (#32571 by @mjankowski) -- Fix missing or incorrect cache-control headers for Streaming server (#32551 by @ThisIsMissEm) -- Fix only the first paragraph being displayed in some notifications (#32348 by @ClearlyClaire) -- Fix reblog icons on account media view (#32506 by @tribela) -- Fix Content-Security-Policy not allowing OpenStack SWIFT object storage URI (#32439 by @kenkiku1021) -- Fix back arrow pointing to the incorrect direction in RTL languages (#32485 by @renchap) -- Fix streaming server using `REDIS_USERNAME` instead of `REDIS_USER` (#32493 by @ThisIsMissEm) -- Fix follow recommendation carrousel scrolling on RTL layouts (#32462 and #32505 by @ClearlyClaire) -- Fix follow recommendation suppressions not applying immediately (#32392 by @ClearlyClaire) -- Fix language of push notifications (#32415 by @ClearlyClaire) -- Fix mute duration not being shown in list of muted accounts in web UI (#32388 by @ClearlyClaire) -- Fix “Mark every notification as read” not updating the read marker if scrolled down (#32385 by @ClearlyClaire) -- Fix “Mention” appearing for otherwise filtered posts (#32356 by @ClearlyClaire) -- Fix notification requests from suspended accounts still being listed (#32354 by @ClearlyClaire) -- Fix list edition modal styling (#32358 and #32367 by @ClearlyClaire and @vmstan) -- Fix 4 columns barely not fitting on 1920px screen (#32361 by @ClearlyClaire) -- Fix icon alignment in applications list (#32293 by @mjankowski) - -## [4.3.0] - 2024-10-08 - -The following changelog entries focus on changes visible to users, administrators, client developers or federated software developers, but there has also been a lot of code modernization, refactoring, and tooling work, in particular by @mjankowski. - -### Security - -- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\ - This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared. -- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx)) -- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 and #32241 by @ClearlyClaire) -- Update dependencies - -### Added - -- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610, #31929, #32089, #32085, #32243, #32179 and #32254 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\ - Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\ - This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\ - As part of this, the visual design of the entire notifications feature has been revamped.\ - This feature is intended to eventually replace the existing notifications column, but for this first beta, users will have to enable it in the “Experimental features” section of the notifications column settings.\ - The API is not final yet, but it consists of: - - a new `group_key` attribute to `Notification` entities - - `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped - - `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group - - `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts - - `POST /api/v2/notifications/:group_key/dismiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group - - `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count -- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\ - The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\ - You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\ - Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\ - This adds the following REST API endpoints: - - `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy - - `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications - - `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests - - `GET /api/v1/notifications/requests/:id`: https://docs.joinmastodon.org/methods/notifications/#get-one-request - - `POST /api/v1/notifications/requests/:id/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-request - - `POST /api/v1/notifications/requests/:id/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-request - - `POST /api/v1/notifications/requests/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-multiple-requests - - `POST /api/v1/notifications/requests/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-multiple-requests - - `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged - - In addition, accepting one or more notification requests generates a new streaming event: - - `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed - -- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\ - Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\ - Note that this does not notify remote users.\ - This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event). -- **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\ - Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\ - This can be disabled in the “Animations and accessibility” section of the preferences. -- **Add "system" theme setting (light/dark theme depending on user system preference)** (#29748, #29553, #29795, #29918, #30839, and #30861 by @nshki, @ErikUden, @mjankowski, @renchap, and @vmstan)\ - Add a “system” theme that automatically switch between default dark and light themes depending on the user's system preferences.\ - Also changes the default server theme to this new “system” theme so that automatic theme selection happens even when logged out. -- **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\ - You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\ - This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link -- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, #31900 and #32188 by @Gargron, @mjankowski and @oneiros)\ - This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\ - Articles hosted outside the fediverse can indicate a fediverse author with a meta tag: - ```html - - ``` - On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors \ - Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\ - This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1 -- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\ - In addition to email notifications, also notify users of moderation actions or warnings against them directly within the app, so they are less likely to miss important communication from their moderators.\ - This adds the `moderation_warning` notification type to the REST API and streaming, with a new [`moderation_warning` attribute](https://docs.joinmastodon.org/entities/Notification/#moderation_warning). -- **Add domain information to profiles in web UI** (#29602 by @Gargron)\ - Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation. -- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\ - See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel -- **Add ability to reorder uploaded media before posting in web UI** (#28456 and #32093 by @Gargron) -- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire) -- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron) -- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron) -- Add reblogs and favourites counts to statuses in ActivityPub (#32007 by @Gargron) -- Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm) -- Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\ - This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon -- Add `api_versions` to `/api/v2/instance` (#31354 by @ClearlyClaire)\ - Add API version number to make it easier for clients to detect compatible features going forward.\ - See API documentation at https://docs.joinmastodon.org/entities/Instance/#api-versions -- Add quick links to Administration and Moderation Reports from Web UI (#24838 by @ThisIsMissEm) -- Add link to `/admin/roles` in moderation interface when changing someone's role (#31791 by @ClearlyClaire) -- Add recent audit log entries in federation moderation interface (#27386 by @ThisIsMissEm) -- Add profile setup to onboarding in web UI (#27829, #27876, and #28453 by @Gargron) -- Add prominent share/copy button on profiles in web UI (#27865 and #27889 by @ClearlyClaire and @Gargron) -- Add optional hints for server rules (#29539 and #29758 by @ClearlyClaire and @Gargron)\ - Server rules can now be broken into a short rule name and a longer explanation of the rule.\ - This adds a new [`hint` attribute](https://docs.joinmastodon.org/entities/Rule/#hint) to `Rule` entities in the REST API. -- Add support for PKCE in OAuth flow (#31129 by @ThisIsMissEm) -- Add CDN cache busting on media deletion (#31353 and #31414 by @ClearlyClaire and @tribela) -- Add the OAuth application used in local reports (#30539 by @ThisIsMissEm) -- Add hint to user that other remote statuses may be missing (#26910, #31387, and #31516 by @Gargron, @audiodude, and @renchap) -- Add lang attribute on preview card title (#31303 by @c960657) -- Add check for `Content-Length` in `ResponseWithLimitAdapter` (#31285 by @c960657) -- Add `Accept-Language` header to fetch preview cards in the server's default language (#31232 by @c960657) -- Add support for PKCE Extension in OmniAuth OIDC through the `OIDC_USE_PKCE` environment variable (#31131 by @ThisIsMissEm) -- Add API endpoints for unread notifications count (#31191 by @ClearlyClaire)\ - This adds the following REST API endpoints: - - `GET /api/v1/notifications/unread_count`: https://docs.joinmastodon.org/methods/notifications/#unread-count -- Add `/` keyboard shortcut to focus the search field (#29921 by @ClearlyClaire) -- Add button to view the Hashtag on the instance from Hashtags in Moderation UI (#31533 by @ThisIsMissEm) -- Add list of pending releases directly in mail notifications for version updates (#29436 and #30035 by @ClearlyClaire) -- Add “Appeals” link under “Moderation” navigation category in moderation interface (#31071 by @ThisIsMissEm) -- Add badge on account card in report moderation interface when account is already suspended (#29592 by @ClearlyClaire) -- Add admin comments directly to the `admin/instances` page (#29240 by @tribela) -- Add ability to require approval when users sign up using specific email domains (#28468, #28732, #28607, and #28608 by @ClearlyClaire) -- Add banner for forwarded reports made by remote users about remote content (#27549 by @ClearlyClaire) -- Add support HTML ruby tags in remote posts for east-asian languages (#30897 by @ThisIsMissEm) -- Add link to manage warning presets in admin navigation (#26199 by @vmstan) -- Add volume saving/reuse to video player (#27488 by @thehydrogen) -- Add Elasticsearch index size, ffmpeg and ImageMagick versions to the admin dashboard (#27301, #30710, #31130, and #30845 by @vmstan) -- Add `MASTODON_SIDEKIQ_READY_FILENAME` environment variable to use a file for Sidekiq to signal it is ready to process jobs (#30971 and #30988 by @renchap)\ - In the official Docker image, this is set to `sidekiq_process_has_started_and_will_begin_processing_jobs` so that Sidekiq will touch `tmp/sidekiq_process_has_started_and_will_begin_processing_jobs` to signal readiness. -- Add `S3_RETRY_LIMIT` environment variable to make S3 retries configurable (#23215 by @smiba) -- Add `S3_KEY_PREFIX` environment variable (#30181 by @S0yKaf) -- Add support for multiple `redirect_uris` when creating OAuth 2.0 Applications (#29192 by @ThisIsMissEm) -- Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap) -- Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc) -- Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan) -- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, #30858 and #32104 by @ClearlyClaire, @Gargron, and @mjankowski)\ - Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\ - This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\ - This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future. -- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm) -- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2) -- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix) -- Add OpenTelemetry instrumentation (#30130, #30322, #30353, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\ - See https://docs.joinmastodon.org/admin/config/#otel for documentation -- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\ - This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index -- Add support for CORS to `POST /oauth/revoke` (#31743 by @ClearlyClaire) -- Add redirection back to previous page after site upload deletion (#30141 by @FawazFarid) -- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm) -- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire) -- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm) -- Add the role ID to the badge component (#29707 by @renchap) -- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski) -- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski) -- Add support for specifying custom CA cert for Elasticsearch through `ES_CA_FILE` (#29122 and #29147 by @ClearlyClaire) -- Add groundwork for annual reports for accounts (#28693 by @Gargron)\ - This lays the groundwork for a “year-in-review”/“wrapped” style report for local users, but is currently not in use. -- Add notification email on invalid second authenticator (#28822 by @ClearlyClaire) -- Add date of account deletion in list of accounts in the admin interface (#25640 by @tribela) -- Add new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem) -- Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\ - This makes the S3 batch size configurable through the `S3_BATCH_DELETE_LIMIT` environment variable (defaults to 1000), and adds some retry logic, configurable through the `S3_BATCH_DELETE_RETRY` environment variable (defaults to 3). -- Add VAPID public key to instance serializer (#28006 by @ThisIsMissEm) -- Add support for serving JRD `/.well-known/host-meta.json` in addition to XRD host-meta (#32206 by @c960657) -- Add `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543) -- Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus) -- Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire) -- Add support for invite codes in the registration API (#27805 by @ClearlyClaire) -- Add HTML lang attribute to preview card descriptions (#27503 by @srapilly) -- Add display of relevant account warnings to report action logs (#27425 by @ClearlyClaire) -- Add validation of allowed schemes on preview card URLs (#27485 by @mjankowski) -- Add token introspection without read scope to `/api/v1/apps/verify_credentials` (#27142 by @ThisIsMissEm) -- Add support for cross-origin request to `/nodeinfo/2.0` (#27413 by @palant) -- Add variable delay before link verification of remote account links (#27351 by @ClearlyClaire) -- Add PWA shortcut to `/explore` page (#27235 by @jake-anto) - -### Changed - -- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, #31525, #32153, and #32201 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\ - This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\ - In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state. -- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, #31889 and #32033 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\ - The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\ - As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”. -- **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\ - The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\ - They also have a more modern and consistent design, along with other confirmation modals in the application. -- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @renchap, and @vmstan) -- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron) -- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, #29879, #32073 and #32132 by @c960657, @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\ - All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients. -- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\ - This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\ - In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\ - This change deprecates the `source` attribute in `Suggestion` entities in the REST API, and replaces it with the new [`sources` attribute](https://docs.joinmastodon.org/entities/Suggestion/#sources). -- Change account search algorithm (#30803 by @Gargron) -- **Change streaming server to use its own dependencies and its own docker image** (#24702, #27967, #26850, #28112, #28115, #28137, #28138, #28497, #28548, #30795, #31612, and #31615 by @TheEssem, @ThisIsMissEm, @jippi, @renchap, @timetinytim, and @vmstan)\ - In order to reduce the amount of runtime dependencies, the streaming server has been moved into a separate package and Docker image.\ - The `mastodon` image does not contain the streaming server anymore, as it has been moved to its own `mastodon-streaming` image.\ - Administrators may need to update their setup accordingly. -- Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron) -- Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros) -- Change embedded posts to use web UI (#31766, #32135 and #32271 by @Gargron) -- Change inner borders in media galleries in web UI (#31852 by @Gargron) -- Change design of media attachments and profile media tab in web UI (#31807, #32048, #31967, #32217, #32224 and #32257 by @ClearlyClaire and @Gargron) -- Change labels on thread indicators in web UI (#31806 by @Gargron) -- Change label of "Data export" menu item in settings interface (#32099 by @c960657) -- Change responsive break points on navigation panel in web UI (#32034 by @Gargron) -- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski) -- Change OAuth authorization prompt to not refer to apps as “third-party” (#32005 by @Gargron) -- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire) -- Change zoom icon in web UI (#29683 by @Gargron) -- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap) -- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm) -- Change width of columns in advanced web UI (#31762 by @Gargron) -- Change design of unread conversations in web UI (#31763 by @Gargron) -- Change Web UI to allow viewing and severing relationships with suspended accounts (#27667 by @ClearlyClaire)\ - This also adds a `with_suspended` parameter to `GET /api/v1/accounts/relationships` in the REST API. -- Change preview card image size limit from 2MB to 8MB when using libvips (#31904 by @ClearlyClaire) -- Change avatars border radius (#31390 by @renchap) -- Change counters to be displayed on profile timelines in web UI (#30525 by @Gargron) -- Change disabled buttons color in light mode to make the difference more visible (#30998 by @renchap) -- Change design of people tab on explore in web UI (#30059 by @Gargron) -- Change sidebar text in web UI (#30696 by @Gargron) -- Change "Follow" to "Follow back" and "Mutual" when appropriate in web UI (#28452, #28465, and #31934 by @ClearlyClaire, @Gargron and @renchap) -- Change media to be hidden/blurred by default in report modal (#28522 by @ClearlyClaire) -- Change order of the "muting" and "blocking" list options in “Data Exports” (#26088 by @fixermark) -- Change admin and moderation notes character limit from 500 to 2000 characters (#30288 by @ThisIsMissEm) -- Change mute options to be in dropdown on muted users list in web UI (#30049 and #31315 by @ClearlyClaire and @Gargron) -- Change out-of-band hashtags design in web UI (#29732 by @Gargron) -- Change design of metadata underneath detailed posts in web UI (#29585, #29605, and #29648 by @ClearlyClaire and @Gargron) -- Change action button to be last on profiles in web UI (#29533 and #29923 by @ClearlyClaire and @Gargron) -- Change confirmation prompts in trending moderation interface to be more specific (#19626 by @tribela) -- Change “Trends” moderation menu to “Recommendations & Trends” and move follow recommendations there (#31292 by @ThisIsMissEm) -- Change irrelevant fields in account cleanup settings to be disabled unless automatic cleanup is enabled (#26562 by @c960657) -- Change dropdown menu icon to not be replaced by close icon when open in web UI (#29532 by @Gargron) -- Change back button to always appear in advanced web UI (#29551 and #29669 by @Gargron) -- Change border of active compose field search inputs (#29832 and #29839 by @vmstan) -- Change instances of Nokogiri HTML4 parsing to HTML5 (#31812, #31815, #31813, and #31814 by @flavorjones) -- Change link detection to allow `@` at the end of an URL (#31124 by @adamniedzielski) -- Change User-Agent to use Mastodon as the product, and http.rb as platform details (#31192 by @ClearlyClaire) -- Change layout and wording of the Content Retention server settings page (#27733 by @vmstan) -- Change unconfirmed users to be kept for one week instead of two days (#30285 by @renchap) -- Change maximum page size for Admin Domain Management APIs from 200 to 500 (#31253 by @ThisIsMissEm) -- Change database pool size to default to Sidekiq concurrency settings in Sidekiq processes (#26488 by @sinoru) -- Change alt text to empty string for avatars (#21875 by @jasminjohal) -- Change Docker images to use custom-built libvips and ffmpeg (#30571, #30569, and #31498 by @vmstan) -- Change external links in the admin audit log to plain text or local administration pages (#27139 and #27150 by @ClearlyClaire and @ThisIsMissEm) -- Change YJIT to be enabled when available (#30310 and #27283 by @ClearlyClaire and @mjankowski)\ - Enable Ruby's built-in just-in-time compiler. This improves performances substantially, at the cost of a slightly increased memory usage. -- Change `.env` file loading from deprecated `dotenv-rails` gem to `dotenv` gem (#29173 and #30121 by @mjankowski)\ - This should have no effect except in the unlikely case an environment variable included a newline. -- Change “Panjabi” language name to the more common spelling “Punjabi” (#27117 by @gunchleoc) -- Change encryption of OTP secrets to use ActiveRecord Encryption (#29831, #28325, #30151, #30202, #30340, and #30344 by @ClearlyClaire and @mjankowski)\ - This requires a manual step from administrators of existing servers. Indeed, they need to generate new secrets, which can be done using `bundle exec rails db:encryption:init`.\ - Furthermore, there is a risk that the introduced migration fails if the server was misconfigured in the past. If that happens, the migration error will include the relevant information. -- Change `/api/v1/announcements` to return regular `Status` entities (#26736 by @ClearlyClaire) -- Change imports to convert case-insensitive fields to lowercase (#29739 and #29740 by @ThisIsMissEm) -- Change stats in the admin interface to be inclusive of the full selected range, from beginning of day to end of day (#29416 and #29841 by @mjankowski) -- Change materialized views to be refreshed concurrently to avoid locks (#29015 by @Gargron) -- Change compose form to use server-provided post character and poll options limits (#28928 and #29490 by @ClearlyClaire and @renchap) -- Change streaming server logging from `npmlog` to `pino` and `pino-http` (#27828 by @ThisIsMissEm)\ - This changes the Mastodon streaming server log format, so this might be considered a breaking change if you were parsing the logs. -- Change media “ALT” label to use a specific CSS class (#28777 by @ClearlyClaire) -- Change streaming API host to not be overridden to localhost in development mode (#28557 by @ClearlyClaire) -- Change cookie rotator to use SHA1 digest for new cookies (#27392 by @ClearlyClaire)\ - Note that this requires that no pre-4.2.0 Mastodon web server is running when this code is deployed, as those would not understand the new cookies.\ - Therefore, zero-downtime updates are only supported if you're coming from 4.2.0 or newer. If you want to skip Mastodon 4.2, you will need to completely stop Mastodon services before updating. -- Change preview card deletes to be done using batch method (#28183 by @vmstan) -- Change `img-src` and `media-src` CSP directives to not include `https:` (#28025 and #28561 by @ClearlyClaire) -- Change self-destruct procedure (#26439, #29049, and #29420 by @ClearlyClaire and @zunda)\ - Instead of enqueuing deletion jobs immediately, `tootctl self-destruct` now outputs a value for the `SELF_DESTRUCT` environment variable, which puts a server in self-destruct mode, processing deletions in the background, while giving users access to their export archives. - -### Removed - -- Remove unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski) -- Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski) -- Remove `CacheBuster` default options (#30718 by @mjankowski) -- Remove home marker updates from the Web UI (#22721 by @davbeck)\ - The web interface was unconditionally updating the home marker to the most recent received post, discarding any value set by other clients, thus making the feature unreliable. -- Remove support for Ruby 3.0 (reaching EOL) (#29702 by @mjankowski) -- Remove setting for unfollow confirmation modal (#29373 by @ClearlyClaire)\ - Instead, the unfollow confirmation modal will always be displayed. -- Remove support for Capistrano (#27295 and #30009 by @mjankowski and @renchap) - -### Fixed - -- **Fix link preview cards not always preserving the original URL from the status** (#27312 by @Gargron) -- Fix log out from user menu not working on Safari (#31402 by @renchap) -- Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela) -- Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski) -- Fix error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire) -- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire) -- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron) -- Fix unresolvable mentions sometimes preventing processing incoming posts (#29215 by @tribela and @ClearlyClaire) -- Fix too many requests caused by relationship look-ups in web UI (#32042 by @Gargron) -- Fix links for reblogs in moderation interface (#31979 by @ClearlyClaire) -- Fix the appearance of avatars when they do not load (#31966 and #32270 by @Gargron and @renchap) -- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657) -- Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil) -- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire) -- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77) -- Fix wrapping in dashboard quick access buttons (#32043 by @renchap) -- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski) -- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski) -- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap) -- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath) -- Fix cutoff of instance name in sign-up form (#30598 by @oneiros) -- Fix invalid date searches returning 503 errors (#31526 by @notchairmk) -- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657) -- Fix some components re-rendering spuriously in web UI (#31879 and #31881 by @ClearlyClaire and @Gargron) -- Fix sort order of moderation notes on Reports and Accounts (#31528 by @ThisIsMissEm) -- Fix email language when recipient has no selected locale (#31747 by @ClearlyClaire) -- Fix frequently-used languages not correctly updating in the web UI (#31386 by @c960657) -- Fix `POST /api/v1/statuses` silently ignoring invalid `media_ids` parameter (#31681 by @c960657) -- Fix handling of the `BIND` environment variable in the streaming server (#31624 by @ThisIsMissEm) -- Fix empty `aria-hidden` attribute value in logo resources area (#30570 by @mjankowski) -- Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm) -- Fix right-to-left text in preview cards (#30930 by @ClearlyClaire) -- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski) -- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, #31445, #32091, #32147 and #32137 by @ClearlyClaire, @mjankowski, @valtlai and @vmstan) -- Fix editing description of media uploads with custom thumbnails (#32221 by @ClearlyClaire) -- Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire) -- Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire) -- Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers) -- Fix unneeded requests to blocked domains when receiving relayed signed activities from them (#31161 by @ClearlyClaire) -- Fix logo pushing header buttons out of view on certain conditions in mobile layout (#29787 by @ClearlyClaire) -- Fix notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire) -- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski) -- Fix distracting and confusing always-showing scrollbar track in boost confirmation modal (#31524 by @ClearlyClaire) -- Fix being able to upload more than 4 media attachments in some cases (#29183 by @mashirozx) -- Fix preview card player getting embedded when clicking on the external link button (#29457 by @ClearlyClaire) -- Fix full date display not respecting the locale 12/24h format (#29448 by @renchap) -- Fix filters title and keywords overflow (#29396 by @GeopJr) -- Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon) -- Fix navigation item active highlight for some paths (#32159 by @mjankowski) -- Fix “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen) -- Fix modal container bounds (#29185 by @nico3333fr) -- Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire) -- Fix moderation report updates through `PUT /api/v1/admin/reports/:id` not being logged in the audit log (#29044, #30342, and #31033 by @mjankowski, @tribela, and @vmstan) -- Fix moderation interface allowing to select rule violation when there are no server rules (#31458 by @ThisIsMissEm) -- Fix redirection from paths with url-encoded `@` to their decoded form (#31184 by @timothyjrogers) -- Fix Trending Tags pending review having an unstable sort order (#31473 by @ThisIsMissEm) -- Fix the emoji dropdown button always opening the dropdown instead of behaving like a toggle (#29012 by @jh97uk) -- Fix processing of incoming posts with bearcaps (#26527 by @kmycode) -- Fix support for IPv6 redis connections in streaming (#31229 by @ThisIsMissEm) -- Fix search form re-rendering spuriously in web UI (#28876 by @Gargron) -- Fix `RedownloadMediaWorker` not being called on transient S3 failure (#28714 by @ClearlyClaire) -- Fix ISO code for Canadian French from incorrect `fr-QC` to `fr-CA` (#26015 by @gunchleoc) -- Fix `.opus` file uploads being misidentified by Paperclip (#28580 by @vmstan) -- Fix loading local accounts with extraneous domain part in WebUI (#28559 by @ClearlyClaire) -- Fix destructive actions in dropdowns not using error color in light theme (#28484 by @logicalmoody) -- Fix call to inefficient `delete_matched` cache method in domain blocks (#28374 by @ClearlyClaire) -- Fix status edits not always being streamed to mentioned users (#28324 by @ClearlyClaire) -- Fix onboarding step descriptions being truncated on narrow screens (#28021 by @ClearlyClaire) -- Fix duplicate IDs in relationships and familiar_followers APIs (#27982 by @KevinBongart) -- Fix modal content not being selectable (#27813 by @pajowu) -- Fix Web UI not displaying appropriate explanation when a user hides their follows/followers (#27791 by @ClearlyClaire) -- Fix format-dependent redirects being cached regardless of requested format (#27632 by @ClearlyClaire) -- Fix confusing screen when visiting a confirmation link for an already-confirmed email (#27368 by @ClearlyClaire) -- Fix explore page reloading when you navigate back to it in web UI (#27489 by @Gargron) -- Fix missing redirection from `/home` to `/deck/home` in the advanced interface (#27378 by @Signez) -- Fix empty environment variables not using default nil value (#27400 by @renchap) -- Fix language sorting in settings (#27158 by @gunchleoc) - -## [4.2.11] - 2024-08-16 - -### Added - -- Add support for incoming `` tag ([mediaformat](https://github.com/mastodon/mastodon/pull/31375)) - -### Changed - -- Change logic of block/mute bypass for mentions from moderators to only apply to visible roles with moderation powers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31271)) - -### Fixed - -- Fix incorrect rate limit on PUT requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31356)) -- Fix presence of `ß` in adjacent word preventing mention and hashtag matching ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31122)) -- Fix processing of webfinger responses with multiple `self` links ([adamniedzielski](https://github.com/mastodon/mastodon/pull/31110)) -- Fix duplicate `orderedItems` in user archive's `outbox.json` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31099)) -- Fix click event handling when clicking outside of an open dropdown menu ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31251)) -- Fix status processing failing halfway when a remote post has a malformed `replies` attribute ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/31246)) -- Fix `--verbose` option of `tootctl media remove`, which was previously erroneously removed ([mjankowski](https://github.com/mastodon/mastodon/pull/30536)) -- Fix division by zero on some video/GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30600)) -- Fix Web UI trying to save user settings despite being logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30324)) -- Fix hashtag regexp matching some link anchors ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30190)) -- Fix local account search on LDAP login being case-sensitive ([raucao](https://github.com/mastodon/mastodon/pull/30113)) -- Fix development environment admin account not being auto-approved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29958)) -- Fix report reason selector in moderation interface not unselecting rules when changing category ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29026)) -- Fix already-invalid reports failing to resolve ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29027)) -- Fix OCR when using S3/CDN for assets ([vmstan](https://github.com/mastodon/mastodon/pull/28551)) -- Fix error when encountering malformed `Tag` objects from Kbin ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28235)) -- Fix not all allowed image formats showing in file picker when uploading custom emoji ([june128](https://github.com/mastodon/mastodon/pull/28076)) -- Fix search popout listing unusable search options when logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27918)) -- Fix processing of featured collections lacking an `items` attribute ([tribela](https://github.com/mastodon/mastodon/pull/27581)) -- Fix `mastodon:stats` decoration of stats rake task ([mjankowski](https://github.com/mastodon/mastodon/pull/31104)) - -## [4.2.10] - 2024-07-04 - -### Security - -- Fix incorrect permission checking on multiple API endpoints ([GHSA-58x8-3qxw-6hm7](https://github.com/mastodon/mastodon/security/advisories/GHSA-58x8-3qxw-6hm7)) -- Fix incorrect authorship checking when processing some activities (CVE-2024-37903, [GHSA-xjvf-fm67-4qc3](https://github.com/mastodon/mastodon/security/advisories/GHSA-xjvf-fm67-4qc3)) -- Fix ongoing streaming sessions not being invalidated when application tokens get revoked ([GHSA-vp5r-5pgw-jwqx](https://github.com/mastodon/mastodon/security/advisories/GHSA-vp5r-5pgw-jwqx)) -- Update dependencies - -### Added - -- Add yarn version specification to avoid confusion with Yarn 3 and Yarn 4 - -### Changed - -- Change preview cards generation to skip unusually long URLs ([oneiros](https://github.com/mastodon/mastodon/pull/30854)) -- Change search modifiers to be case-insensitive ([Gargron](https://github.com/mastodon/mastodon/pull/30865)) -- Change `STATSD_ADDR` handling to emit a warning rather than crashing if the address is unreachable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30691)) -- Change PWA start URL from `/home` to `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27377)) - -### Removed - -- Removed dependency on `posix-spawn` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18559)) - -### Fixed - -- Fix scheduled statuses scheduled in less than 5 minutes being immediately published ([danielmbrasil](https://github.com/mastodon/mastodon/pull/30584)) -- Fix encoding detection for link cards ([oneiros](https://github.com/mastodon/mastodon/pull/30780)) -- Fix `/admin/accounts/:account_id/statuses/:id` for edited posts with media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30819)) -- Fix duplicate `@context` attribute in user archive export ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30653)) - -## [4.2.9] - 2024-05-30 - -### Security - -- Update dependencies -- Fix private mention filtering ([GHSA-5fq7-3p3j-9vrf](https://github.com/mastodon/mastodon/security/advisories/GHSA-5fq7-3p3j-9vrf)) -- Fix password change endpoint not being rate-limited ([GHSA-q3rg-xx5v-4mxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-q3rg-xx5v-4mxh)) -- Add hardening around rate-limit bypass ([GHSA-c2r5-cfqr-c553](https://github.com/mastodon/mastodon/security/advisories/GHSA-c2r5-cfqr-c553)) - -### Added - -- Add rate-limit on OAuth application registration ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30316)) -- Add fallback redirection when getting a webfinger query `WEB_DOMAIN@WEB_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28592)) -- Add `digest` attribute to `Admin::DomainBlock` entity in REST API ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29092)) - -### Removed - -- Remove superfluous application-level caching in some controllers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29862)) -- Remove aggressive OAuth application vacuuming ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30316)) - -### Fixed - -- Fix leaking Elasticsearch connections in Sidekiq processes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30450)) -- Fix language of remote posts not being recognized when using unusual casing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30403)) -- Fix off-by-one in `tootctl media` commands ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30306)) -- Fix removal of allowed domains (in `LIMITED_FEDERATION_MODE`) not being recorded in the audit log ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/30125)) -- Fix not being able to block a subdomain of an already-blocked domain through the API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30119)) -- Fix `Idempotency-Key` being ignored when scheduling a post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30084)) -- Fix crash when supplying the `FFMPEG_BINARY` environment variable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30022)) -- Fix improper email address validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29838)) -- Fix results/query in `api/v1/featured_tags/suggestions` ([mjankowski](https://github.com/mastodon/mastodon/pull/29597)) -- Fix unblocking internationalized domain names under certain conditions ([tribela](https://github.com/mastodon/mastodon/pull/29530)) -- Fix admin account created by `mastodon:setup` not being auto-approved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29379)) -- Fix reference to non-existent var in CLI maintenance command ([mjankowski](https://github.com/mastodon/mastodon/pull/28363)) - -## [4.2.8] - 2024-02-23 - -### Added - -- Add hourly task to automatically require approval for new registrations in the absence of moderators ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29318), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29355)) - In order to prevent future abandoned Mastodon servers from being used for spam, harassment and other malicious activity, Mastodon will now automatically switch new user registrations to require moderator approval whenever they are left open and no activity (including non-moderation actions from apps) from any logged-in user with permission to access moderation reports has been detected in a full week. - When this happens, users with the permission to change server settings will receive an email notification. - This feature is disabled when `EMAIL_DOMAIN_ALLOWLIST` is used, and can also be disabled with `DISABLE_AUTOMATIC_SWITCHING_TO_APPROVED_REGISTRATIONS=true`. - -### Changed - -- Change registrations to be closed by default on new installations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29280)) - If you are running a server and never changed your registrations mode from the default, updating will automatically close your registrations. - Simply re-enable them through the administration interface or using `tootctl settings registrations open` if you want to enable them again. - -### Fixed - -- Fix processing of remote ActivityPub actors making use of `Link` objects as `Image` `url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29335)) -- Fix link verifications when page size exceeds 1MB ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29358)) - -## [4.2.7] - 2024-02-16 - -### Fixed - -- Fix OmniAuth tests and edge cases in error handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29201), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29207)) -- Fix new installs by upgrading to the latest release of the `nsa` gem, instead of a no longer existing commit ([mjankowski](https://github.com/mastodon/mastodon/pull/29065)) - -### Security - -- Fix insufficient checking of remote posts ([GHSA-jhrq-qvrm-qr36](https://github.com/mastodon/mastodon/security/advisories/GHSA-jhrq-qvrm-qr36)) - -## [4.2.6] - 2024-02-14 - -### Security - -- Update the `sidekiq-unique-jobs` dependency (see [GHSA-cmh9-rx85-xj38](https://github.com/mhenrixon/sidekiq-unique-jobs/security/advisories/GHSA-cmh9-rx85-xj38)) - In addition, we have disabled the web interface for `sidekiq-unique-jobs` out of caution. - If you need it, you can re-enable it by setting `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI=true`. - If you only need to clear all locks, you can now use `bundle exec rake sidekiq_unique_jobs:delete_all_locks`. -- Update the `nokogiri` dependency (see [GHSA-xc9x-jj77-9p9j](https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-xc9x-jj77-9p9j)) -- Disable administrative Doorkeeper routes ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29187)) -- Fix ongoing streaming sessions not being invalidated when applications get deleted in some cases ([GHSA-7w3c-p9j8-mq3x](https://github.com/mastodon/mastodon/security/advisories/GHSA-7w3c-p9j8-mq3x)) - In some rare cases, the streaming server was not notified of access tokens revocation on application deletion. -- Change external authentication behavior to never reattach a new identity to an existing user by default ([GHSA-vm39-j3vx-pch3](https://github.com/mastodon/mastodon/security/advisories/GHSA-vm39-j3vx-pch3)) - Up until now, Mastodon has allowed new identities from external authentication providers to attach to an existing local user based on their verified e-mail address. - This allowed upgrading users from a database-stored password to an external authentication provider, or move from one authentication provider to another. - However, this behavior may be unexpected, and means that when multiple authentication providers are configured, the overall security would be that of the least secure authentication provider. - For these reasons, this behavior is now locked under the `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH` environment variable. - In addition, regardless of this environment variable, Mastodon will refuse to attach two identities from the same authentication provider to the same account. - -## [4.2.5] - 2024-02-01 - -### Security - -- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw)) - -## [4.2.4] - 2024-01-24 - -### Fixed - -- Fix error when processing remote files with unusually long names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28823)) -- Fix processing of compacted single-item JSON-LD collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28816)) -- Retry 401 errors on replies fetching ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28788)) -- Fix `RecordNotUnique` errors in LinkCrawlWorker ([tribela](https://github.com/mastodon/mastodon/pull/28748)) -- Fix Mastodon not correctly processing HTTP Signatures with query strings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28443), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28476)) -- Fix potential redirection loop of streaming endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28665)) -- Fix streaming API redirection ignoring the port of `streaming_api_base_url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28558)) -- Fix error when processing link preview with an array as `inLanguage` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28252)) -- Fix unsupported time zone or locale preventing sign-up ([Gargron](https://github.com/mastodon/mastodon/pull/28035)) -- Fix "Hide these posts from home" list setting not refreshing when switching lists ([brianholley](https://github.com/mastodon/mastodon/pull/27763)) -- Fix missing background behind dismissable banner in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/27479)) -- Fix line wrapping of language selection button with long locale codes ([gunchleoc](https://github.com/mastodon/mastodon/pull/27100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27127)) -- Fix `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482)) -- Fix N+1s because of association preloaders not actually getting called ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28339)) -- Fix empty column explainer getting cropped under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28337)) -- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268)) -- Fix call to inefficient `delete_matched` cache method in domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28367)) - -### Security - -- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801)) - -## [4.2.3] - 2023-12-05 - -### Fixed - -- Fix dependency on `json-canonicalization` version that has been made unavailable since last release - -## [4.2.2] - 2023-12-04 - -### Changed - -- Change dismissed banners to be stored server-side ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27055)) -- Change GIF max matrix size error to explicitly mention GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27927)) -- Change `Follow` activities delivery to bypass availability check ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/27586)) -- Change single-column navigation notice to be displayed outside of the logo container ([renchap](https://github.com/mastodon/mastodon/pull/27462), [renchap](https://github.com/mastodon/mastodon/pull/27476)) -- Change Content-Security-Policy to be tighter on media paths ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26889)) -- Change post language code to include country code when relevant ([gunchleoc](https://github.com/mastodon/mastodon/pull/27099), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27207)) - -### Fixed - -- Fix upper border radius of onboarding columns ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27890)) -- Fix incoming status creation date not being restricted to standard ISO8601 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27655), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28081)) -- Fix some posts from threads received out-of-order sometimes not being inserted into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27653)) -- Fix posts from force-sensitized accounts being able to trend ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27620)) -- Fix error when trying to delete already-deleted file with OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27569)) -- Fix batch attachment deletion when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27554)) -- Fix processing LDSigned activities from actors with unknown public keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27474)) -- Fix error and incorrect URLs in `/api/v1/accounts/:id/featured_tags` for remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27459)) -- Fix report processing notice not mentioning the report number when performing a custom action ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27442)) -- Fix handling of `inLanguage` attribute in preview card processing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27423)) -- Fix own posts being removed from home timeline when unfollowing a used hashtag ([kmycode](https://github.com/mastodon/mastodon/pull/27391)) -- Fix some link anchors being recognized as hashtags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27271), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27584)) -- Fix format-dependent redirects being cached regardless of requested format ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27634)) - -## [4.2.1] - 2023-10-10 - -### Added - -- Add redirection on `/deck` URLs for logged-out users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27128)) -- Add support for v4.2.0 migrations to `tootctl maintenance fix-duplicates` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27147)) - -### Changed - -- Change some worker lock TTLs to be shorter-lived ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27246)) -- Change user archive export allowed period from 7 days to 6 days ([suddjian](https://github.com/mastodon/mastodon/pull/27200)) - -### Fixed - -- Fix duplicate reports being sent when reporting some remote posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27355)) -- Fix clicking on already-opened thread post scrolling to the top of the thread ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27331), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27338), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27350)) -- Fix some remote posts getting truncated ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27307)) -- Fix some cases of infinite scroll code trying to fetch inaccessible posts in a loop ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27286)) -- Fix `Vary` headers not being set on some redirects ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27272)) -- Fix mentions being matched in some URL query strings ([mjankowski](https://github.com/mastodon/mastodon/pull/25656)) -- Fix unexpected linebreak in version string in the Web UI ([vmstan](https://github.com/mastodon/mastodon/pull/26986)) -- Fix double scroll bars in some columns in advanced interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27187)) -- Fix boosts of local users being filtered in account timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27204)) -- Fix multiple instances of the trend refresh scheduler sometimes running at once ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27253)) -- Fix importer returning negative row estimates ([jgillich](https://github.com/mastodon/mastodon/pull/27258)) -- Fix incorrectly keeping outdated update notices absent from the API endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27021)) -- Fix import progress not updating on certain failures ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27247)) -- Fix websocket connections being incorrectly decremented twice on errors ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/27238)) -- Fix explore prompt appearing because of posts being received out of order ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27211)) -- Fix explore prompt sometimes showing up when the home TL is loading ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27062)) -- Fix link handling of mentions in user profiles when logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27185)) -- Fix filtering audit log for entries about disabling 2FA ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27186)) -- Fix notification toasts not respecting reduce-motion ([c960657](https://github.com/mastodon/mastodon/pull/27178)) -- Fix retention dashboard not displaying correct month ([vmstan](https://github.com/mastodon/mastodon/pull/27180)) -- Fix tIME chunk not being properly removed from PNG uploads ([TheEssem](https://github.com/mastodon/mastodon/pull/27111)) -- Fix division by zero in video in bitrate computation code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27129)) -- Fix inefficient queries in “Follows and followers” as well as several admin pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27116), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27306)) -- Fix ActiveRecord using two connection pools when no replica is defined ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27061)) -- Fix the search documentation URL in system checks ([renchap](https://github.com/mastodon/mastodon/pull/27036)) - -## [4.2.0] - 2023-09-21 - -The following changelog entries focus on changes visible to users, administrators, client developers or federated software developers, but there has also been a lot of code modernization, refactoring, and tooling work, in particular by [@danielmbrasil](https://github.com/danielmbrasil), [@mjankowski](https://github.com/mjankowski), [@nschonni](https://github.com/nschonni), [@renchap](https://github.com/renchap), and [@takayamaki](https://github.com/takayamaki). - -### Added - -- **Add full-text search of opted-in public posts and rework search operators** ([Gargron](https://github.com/mastodon/mastodon/pull/26485), [jsgoldstein](https://github.com/mastodon/mastodon/pull/26344), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26657), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26650), [jsgoldstein](https://github.com/mastodon/mastodon/pull/26659), [Gargron](https://github.com/mastodon/mastodon/pull/26660), [Gargron](https://github.com/mastodon/mastodon/pull/26663), [Gargron](https://github.com/mastodon/mastodon/pull/26688), [Gargron](https://github.com/mastodon/mastodon/pull/26689), [Gargron](https://github.com/mastodon/mastodon/pull/26686), [Gargron](https://github.com/mastodon/mastodon/pull/26687), [Gargron](https://github.com/mastodon/mastodon/pull/26692), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26697), [Gargron](https://github.com/mastodon/mastodon/pull/26699), [Gargron](https://github.com/mastodon/mastodon/pull/26701), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26710), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26739), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26754), [Gargron](https://github.com/mastodon/mastodon/pull/26662), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26755), [Gargron](https://github.com/mastodon/mastodon/pull/26781), [Gargron](https://github.com/mastodon/mastodon/pull/26782), [Gargron](https://github.com/mastodon/mastodon/pull/26760), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26756), [Gargron](https://github.com/mastodon/mastodon/pull/26784), [Gargron](https://github.com/mastodon/mastodon/pull/26807), [Gargron](https://github.com/mastodon/mastodon/pull/26835), [Gargron](https://github.com/mastodon/mastodon/pull/26847), [Gargron](https://github.com/mastodon/mastodon/pull/26834), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26893), [tribela](https://github.com/mastodon/mastodon/pull/26896), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26927), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27014)) - This introduces a new `public_statuses` Elasticsearch index for public posts by users who have opted in to their posts being searchable (`toot#indexable` flag). - This also revisits the other indexes to provide more useful indexing, and adds new search operators such as `from:me`, `before:2022-11-01`, `after:2022-11-01`, `during:2022-11-01`, `language:fr`, `has:poll`, or `in:library` (for searching only in posts you have written or interacted with). - Results are now ordered chronologically. -- **Add admin notifications for new Mastodon versions** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26582)) - This is done by querying `https://api.joinmastodon.org/update-check` every 30 minutes in a background job. - That URL can be changed using the `UPDATE_CHECK_URL` environment variable, and the feature outright disabled by setting that variable to an empty string (`UPDATE_CHECK_URL=`). -- **Add “Privacy and reach” tab in profile settings** ([Gargron](https://github.com/mastodon/mastodon/pull/26484), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26508)) - This reorganized scattered privacy and reach settings to a single place, as well as improve their wording. -- **Add display of out-of-band hashtags in the web interface** ([Gargron](https://github.com/mastodon/mastodon/pull/26492), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26497), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26506), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26525), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26606), [Gargron](https://github.com/mastodon/mastodon/pull/26666), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26960)) -- **Add role badges to the web interface** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25649), [Gargron](https://github.com/mastodon/mastodon/pull/26281)) -- **Add ability to pick domains to forward reports to using the `forward_to_domains` parameter in `POST /api/v1/reports`** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25866), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26636)) - The `forward_to_domains` REST API parameter is a list of strings. If it is empty or omitted, the previous behavior is maintained. - The `forward` parameter still needs to be set for `forward_to_domains` to be taken into account. - The forwarded-to domains can only include that of the original author and people being replied to. -- **Add forwarding of reported replies to servers being replied to** ([Gargron](https://github.com/mastodon/mastodon/pull/25341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26189)) -- Add `ONE_CLICK_SSO_LOGIN` environment variable to directly link to the Single-Sign On provider if there is only one sign up method available ([CSDUMMI](https://github.com/mastodon/mastodon/pull/26083), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26368), [CSDUMMI](https://github.com/mastodon/mastodon/pull/26857), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26901)) -- **Add webhook templating** ([Gargron](https://github.com/mastodon/mastodon/pull/23289)) -- **Add webhooks for local `status.created`, `status.updated`, `account.updated` and `report.updated`** ([VyrCossont](https://github.com/mastodon/mastodon/pull/24133), [VyrCossont](https://github.com/mastodon/mastodon/pull/24243), [VyrCossont](https://github.com/mastodon/mastodon/pull/24211)) -- **Add exclusive lists** ([dariusk, necropolina](https://github.com/mastodon/mastodon/pull/22048), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25324)) -- **Add a confirmation screen when suspending a domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25144), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25603)) -- **Add support for importing lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25203), [mgmn](https://github.com/mastodon/mastodon/pull/26120), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26372)) -- **Add optional hCaptcha support** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25019), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25057), [Gargron](https://github.com/mastodon/mastodon/pull/25395), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26388)) -- **Add lines to threads in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24549), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24677), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24696), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24711), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24713), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24715), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24800), [teeerevor](https://github.com/mastodon/mastodon/pull/25706), [renchap](https://github.com/mastodon/mastodon/pull/25807)) -- **Add new onboarding flow to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24619), [Gargron](https://github.com/mastodon/mastodon/pull/24646), [Gargron](https://github.com/mastodon/mastodon/pull/24705), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24872), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24883), [Gargron](https://github.com/mastodon/mastodon/pull/24954), [stevenjlm](https://github.com/mastodon/mastodon/pull/24959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25010), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25275), [Gargron](https://github.com/mastodon/mastodon/pull/25559), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25561)) -- **Add auto-refresh of accounts we get new messages/edits of** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26510)) -- **Add Elasticsearch cluster health check and indexes mismatch check to dashboard** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26448), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26605), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26658)) -- Add `hide_collections`, `discoverable` and `indexable` attributes to credentials API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26998)) -- Add `S3_ENABLE_CHECKSUM_MODE` environment variable to enable checksum verification on compatible S3-providers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26435)) -- Add admin API for managing tags ([rrgeorge](https://github.com/mastodon/mastodon/pull/26872)) -- Add a link to hashtag timelines from the Trending hashtags moderation interface ([gunchleoc](https://github.com/mastodon/mastodon/pull/26724)) -- Add timezone to datetimes in e-mails ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26822)) -- Add `authorized_fetch` server setting in addition to env var ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25798), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26958)) -- Add avatar image to webfinger responses ([tvler](https://github.com/mastodon/mastodon/pull/26558)) -- Add debug logging on signature verification failure ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26637), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26812)) -- Add explicit error messages when DeepL quota is exceeded ([lutoma](https://github.com/mastodon/mastodon/pull/26704)) -- Add Elasticsearch/OpenSearch version to “Software” in admin dashboard ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26652)) -- Add `data-nosnippet` attribute to remote posts and local posts with `noindex` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26648)) -- Add support for federating `memorial` attribute ([rrgeorge](https://github.com/mastodon/mastodon/pull/26583)) -- Add Cherokee and Kalmyk to languages dropdown ([gunchleoc](https://github.com/mastodon/mastodon/pull/26012), [gunchleoc](https://github.com/mastodon/mastodon/pull/26013)) -- Add `DELETE /api/v1/profile/avatar` and `DELETE /api/v1/profile/header` to the REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25124), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26573)) -- Add `ES_PRESET` option to customize numbers of shards and replicas ([Gargron](https://github.com/mastodon/mastodon/pull/26483), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26489)) - This can have a value of `single_node_cluster` (default), `small_cluster` (uses one replica) or `large_cluster` (uses one replica and a higher number of shards). -- Add `CACHE_BUSTER_HTTP_METHOD` environment variable ([renchap](https://github.com/mastodon/mastodon/pull/26528), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26542)) -- Add support for `DB_PASS` when using `DATABASE_URL` ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26295)) -- Add `GET /api/v1/instance/languages` to REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24443)) -- Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26447), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26737), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26979)) -- Add client-side timeout on resend confirmation button ([Gargron](https://github.com/mastodon/mastodon/pull/26300)) -- Add published date and author to news on the explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26155)) -- Add `lang` attribute to various UI components ([c960657](https://github.com/mastodon/mastodon/pull/23869), [c960657](https://github.com/mastodon/mastodon/pull/23891), [c960657](https://github.com/mastodon/mastodon/pull/26111), [c960657](https://github.com/mastodon/mastodon/pull/26149)) -- Add stricter protocol fields validation for accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25937)) -- Add support for Azure blob storage ([mistydemeo](https://github.com/mastodon/mastodon/pull/23607), [mistydemeo](https://github.com/mastodon/mastodon/pull/26080)) -- Add toast with option to open post after publishing in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25564), [Signez](https://github.com/mastodon/mastodon/pull/25919), [Gargron](https://github.com/mastodon/mastodon/pull/26664)) -- Add canonical link tags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25715)) -- Add button to see results for polls in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25726)) -- Add at-symbol prepended to mention span title ([forsamori](https://github.com/mastodon/mastodon/pull/25684)) -- Add users index on `unconfirmed_email` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25672), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25702)) -- Add superapp index on `oauth_applications` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25670)) -- Add index to backups on `user_id` column ([mjankowski](https://github.com/mastodon/mastodon/pull/25647)) -- Add onboarding prompt when home feed too slow in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25267), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25556), [Gargron](https://github.com/mastodon/mastodon/pull/25579), [renchap](https://github.com/mastodon/mastodon/pull/25580), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25581), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25617), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25917), [Gargron](https://github.com/mastodon/mastodon/pull/26829), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26935)) -- Add `POST /api/v1/conversations/:id/unread` API endpoint to mark a conversation as unread ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25509)) -- Add `translate="no"` to outgoing mentions and links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25524)) -- Add unsubscribe link and headers to e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/25378), [c960657](https://github.com/mastodon/mastodon/pull/26085)) -- Add logging of websocket send errors ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25280)) -- Add time zone preference ([Gargron](https://github.com/mastodon/mastodon/pull/25342), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26025)) -- Add `legal` as report category ([Gargron](https://github.com/mastodon/mastodon/pull/23941), [renchap](https://github.com/mastodon/mastodon/pull/25400), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26509)) -- Add `data-nosnippet` so Google doesn't use trending posts in snippets for `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25279)) -- Add card with who invited you to join when displaying rules on sign-up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23475)) -- Add missing primary keys to `accounts_tags` and `statuses_tags` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25210)) -- Add support for custom sign-up URLs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25014), [renchap](https://github.com/mastodon/mastodon/pull/25108), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25190), [mgmn](https://github.com/mastodon/mastodon/pull/25531)) - This is set using `SSO_ACCOUNT_SIGN_UP` and reflected in the REST API by adding `registrations.sign_up_url` to the `/api/v2/instance` endpoint. -- Add polling and automatic redirection to `/start` on email confirmation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25013)) -- Add ability to block sign-ups from IP using the CLI ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24870)) -- Add ALT badges to media that has alternative text in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24782), [c960657](https://github.com/mastodon/mastodon/pull/26166) -- Add ability to include accounts with pending follow requests in lists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19727), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24810)) -- Add trend management to admin API ([rrgeorge](https://github.com/mastodon/mastodon/pull/24257)) - - `POST /api/v1/admin/trends/statuses/:id/approve` - - `POST /api/v1/admin/trends/statuses/:id/reject` - - `POST /api/v1/admin/trends/links/:id/approve` - - `POST /api/v1/admin/trends/links/:id/reject` - - `POST /api/v1/admin/trends/tags/:id/approve` - - `POST /api/v1/admin/trends/tags/:id/reject` - - `GET /api/v1/admin/trends/links/publishers` - - `POST /api/v1/admin/trends/links/publishers/:id/approve` - - `POST /api/v1/admin/trends/links/publishers/:id/reject` -- Add user handle to notification mail recipient address ([HeitorMC](https://github.com/mastodon/mastodon/pull/24240)) -- Add progress indicator to sign-up flow ([Gargron](https://github.com/mastodon/mastodon/pull/24545)) -- Add client-side validation for taken username in sign-up form ([Gargron](https://github.com/mastodon/mastodon/pull/24546)) -- Add `--approve` option to `tootctl accounts create` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24533)) -- Add “In Memoriam” banner back to profiles ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23591), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23614)) - This adds the `memorial` attribute to the `Account` REST API entity. -- Add colour to follow button when hashtag is being followed ([c960657](https://github.com/mastodon/mastodon/pull/24361)) -- Add further explanations to the profile link verification instructions ([drzax](https://github.com/mastodon/mastodon/pull/19723)) -- Add a link to Identity provider's account settings from the account settings ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24628)) -- Add support for streaming server to connect to postgres with self-signed certs through the `sslmode` URL parameter ([ramuuns](https://github.com/mastodon/mastodon/pull/21431)) -- Add support for specifying S3 storage classes through the `S3_STORAGE_CLASS` environment variable ([hyl](https://github.com/mastodon/mastodon/pull/22480)) -- Add support for incoming rich text ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23913)) -- Add support for Ruby 3.2 ([tenderlove](https://github.com/mastodon/mastodon/pull/22928), [casperisfine](https://github.com/mastodon/mastodon/pull/24142), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24202), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26934)) -- Add API parameter to safeguard unexpected mentions in new posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18350)) - -### Changed - -- **Change hashtags to be displayed separately when they are the last line of a post** ([renchap](https://github.com/mastodon/mastodon/pull/26499), [renchap](https://github.com/mastodon/mastodon/pull/26614), [renchap](https://github.com/mastodon/mastodon/pull/26615)) -- **Change reblogs to be excluded from "Posts and replies" tab in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26302)) -- **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267), [mgmn](https://github.com/mastodon/mastodon/pull/26459), [tribela](https://github.com/mastodon/mastodon/pull/26461), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26593), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26795)) -- **Change design of link previews in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26136), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26151), [Gargron](https://github.com/mastodon/mastodon/pull/26153), [Gargron](https://github.com/mastodon/mastodon/pull/26250), [Gargron](https://github.com/mastodon/mastodon/pull/26287), [Gargron](https://github.com/mastodon/mastodon/pull/26286), [c960657](https://github.com/mastodon/mastodon/pull/26184)) -- **Change "direct message" nomenclature to "private mention" in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24248)) -- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452)) -- **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378)) -- **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874)) -- **Change local and federated timelines to be tabs of a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26633)) -- **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034)) -- **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751)) -- **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310)) - This deprecates `statsd` support and disables the sidekiq integration unless `STATSD_SIDEKIQ` is set to `true`. - This is because the `nsa` gem is unmaintained, and its sidekiq integration is known to add very significant overhead. - Later versions of Mastodon will have other ways to get the same metrics. -- **Change replica support to native Rails adapter** ([krainboltgreene](https://github.com/mastodon/mastodon/pull/25693), [Gargron](https://github.com/mastodon/mastodon/pull/25849), [Gargron](https://github.com/mastodon/mastodon/pull/25874), [Gargron](https://github.com/mastodon/mastodon/pull/25851), [Gargron](https://github.com/mastodon/mastodon/pull/25977), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26074), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26386), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26856)) - This is a breaking change, dropping `makara` support, and requiring you to update your database configuration if you are using replicas. - To tell Mastodon to use a read replica, you can either set the `REPLICA_DB_NAME` environment variable (along with `REPLICA_DB_USER`, `REPLICA_DB_PASS`, `REPLICA_DB_HOST`, and `REPLICA_DB_PORT`, if they differ from the primary database), or the `REPLICA_DATABASE_URL` environment variable if your configuration is based on `DATABASE_URL`. -- Change DCT method used for JPEG encoding to float ([electroCutie](https://github.com/mastodon/mastodon/pull/26675)) -- Change from `node-redis` to `ioredis` for streaming ([gmemstr](https://github.com/mastodon/mastodon/pull/26581)) -- Change private statuses index to index without crutches ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26713)) -- Change video compression parameters ([Gargron](https://github.com/mastodon/mastodon/pull/26631), [Gargron](https://github.com/mastodon/mastodon/pull/26745), [Gargron](https://github.com/mastodon/mastodon/pull/26766), [Gargron](https://github.com/mastodon/mastodon/pull/26970)) -- Change admin e-mail notification settings to be their own settings group ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26596)) -- Change opacity of the delete icon in the search field to be more visible ([AntoninDelFabbro](https://github.com/mastodon/mastodon/pull/26449)) -- Change Account Search to prioritize username over display name ([jsgoldstein](https://github.com/mastodon/mastodon/pull/26623)) -- Change follow recommendation materialized view to be faster in most cases ([renchap, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26545)) -- Change `robots.txt` to block GPTBot ([Foritus](https://github.com/mastodon/mastodon/pull/26396)) -- Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26416)) -- Change streaming `/metrics` to include additional metrics ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26299), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26945)) -- Change indexing frequency from 5 minutes to 1 minute, add locks to schedulers ([Gargron](https://github.com/mastodon/mastodon/pull/26304)) -- Change column link to add a better keyboard focus indicator ([teeerevor](https://github.com/mastodon/mastodon/pull/26278)) -- Change poll form element colors to fit with the rest of the ui ([teeerevor](https://github.com/mastodon/mastodon/pull/26139), [teeerevor](https://github.com/mastodon/mastodon/pull/26162), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26164)) -- Change 'favourite' to 'favorite' for American English ([marekr](https://github.com/mastodon/mastodon/pull/24667), [gunchleoc](https://github.com/mastodon/mastodon/pull/26009), [nabijaczleweli](https://github.com/mastodon/mastodon/pull/26109)) -- Change ActivityStreams representation of suspended accounts to not use a blank `name` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25276)) -- Change focus UI for keyboard only input ([teeerevor](https://github.com/mastodon/mastodon/pull/25935), [Gargron](https://github.com/mastodon/mastodon/pull/26125), [Gargron](https://github.com/mastodon/mastodon/pull/26767)) -- Change thread view to scroll to the selected post rather than the post being replied to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24685)) -- Change links in multi-column mode so tabs are open in single-column mode ([Signez](https://github.com/mastodon/mastodon/pull/25893), [Signez](https://github.com/mastodon/mastodon/pull/26070), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25973), [Signez](https://github.com/mastodon/mastodon/pull/26019), [Signez](https://github.com/mastodon/mastodon/pull/26759)) -- Change searching with `#` to include account index ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25638)) -- Change label and design of sensitive and unavailable media in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25712), [Gargron](https://github.com/mastodon/mastodon/pull/26135), [Gargron](https://github.com/mastodon/mastodon/pull/26330)) -- Change button colors to increase hover/focus contrast and consistency ([teeerevor](https://github.com/mastodon/mastodon/pull/25677), [Gargron](https://github.com/mastodon/mastodon/pull/25679)) -- Change dropdown icon above compose form from ellipsis to bars in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25661)) -- Change header backgrounds to use fewer different colors in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25577)) -- Change files to be deleted in batches instead of one-by-one ([Gargron](https://github.com/mastodon/mastodon/pull/23302), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25586), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25587)) -- Change emoji picker icon ([iparr](https://github.com/mastodon/mastodon/pull/25479)) -- Change edit profile page ([Gargron](https://github.com/mastodon/mastodon/pull/25413), [c960657](https://github.com/mastodon/mastodon/pull/26538)) -- Change "bot" label to "automated" ([Gargron](https://github.com/mastodon/mastodon/pull/25356)) -- Change design of dropdowns in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25107)) -- Change wording of “Content cache retention period” setting to highlight destructive implications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23261)) -- Change autolinking to allow carets in URL search params ([renchap](https://github.com/mastodon/mastodon/pull/25216)) -- Change share action from being in action bar to being in dropdown in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25105)) -- Change sessions to be ordered from most-recent to least-recently updated ([frankieroberto](https://github.com/mastodon/mastodon/pull/25005)) -- Change vacuum scheduler to also delete expired tokens and unused application records ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24871)) -- Change "Sign in" to "Login" ([Gargron](https://github.com/mastodon/mastodon/pull/24942)) -- Change domain suspensions to also be checked before trying to fetch unknown remote resources ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24535)) -- Change media components to use aspect-ratio rather than compute height themselves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24686), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24943), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26801)) -- Change logo version in header based on screen size in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24707)) -- Change label from "For you" to "People" on explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24706)) -- Change logged-out WebUI HTML pages to be cached for a few seconds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24708)) -- Change unauthenticated responses to be cached in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/24348), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24662), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24665)) -- Change HTTP caching logic ([Gargron](https://github.com/mastodon/mastodon/pull/24347), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24604)) -- Change hashtags and mentions in bios to open in-app in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24643)) -- Change styling of the recommended accounts to allow bio to be more visible ([chike00](https://github.com/mastodon/mastodon/pull/24480)) -- Change account search in moderation interface to allow searching by username including the leading `@` ([HeitorMC](https://github.com/mastodon/mastodon/pull/24242)) -- Change all components to use the same error page in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24512)) -- Change search pop-out in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24305)) -- Change user settings to be stored in a more optimal way ([Gargron](https://github.com/mastodon/mastodon/pull/23630), [c960657](https://github.com/mastodon/mastodon/pull/24321), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24453), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24460), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24558), [Gargron](https://github.com/mastodon/mastodon/pull/24761), [Gargron](https://github.com/mastodon/mastodon/pull/24783), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25508), [jsgoldstein](https://github.com/mastodon/mastodon/pull/25340), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26884), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27012)) -- Change media upload limits and remove client-side resizing ([Gargron](https://github.com/mastodon/mastodon/pull/23726)) -- Change design of account rows in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24247), [Gargron](https://github.com/mastodon/mastodon/pull/24343), [Gargron](https://github.com/mastodon/mastodon/pull/24956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25131)) -- Change log-out to use Single Logout when using external log-in through OIDC ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24020)) -- Change sidekiq-bulk's batch size from 10,000 to 1,000 jobs in one Redis call ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24034)) -- Change translation to only be offered for supported languages ([c960657](https://github.com/mastodon/mastodon/pull/23879), [c960657](https://github.com/mastodon/mastodon/pull/24037)) - This adds the `/api/v1/instance/translation_languages` REST API endpoint that returns an object with the supported translation language pairs in the form: - ```json - { - "fr": ["en", "de"] - } - ``` - (where `fr` is a supported source language and `en` and `de` or supported output language when translating a `fr` string) -- Change compose form checkbox to native input with `appearance: none` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22949)) -- Change posts' clickable area to be larger ([c960657](https://github.com/mastodon/mastodon/pull/23621)) -- Change `followed_by` link to `location=all` if account is local on /admin/accounts/:id page ([tribela](https://github.com/mastodon/mastodon/pull/23467)) - -### Removed - -- **Remove support for Node.js 14** ([renchap](https://github.com/mastodon/mastodon/pull/25198)) -- **Remove support for Ruby 2.7** ([nschonni](https://github.com/mastodon/mastodon/pull/24237)) -- **Remove clustering from streaming API** ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24655)) -- **Remove anonymous access to the streaming API** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23989)) -- Remove obfuscation of reply count in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26768)) -- Remove `kmr` from language selection, as it was a duplicate for `ku` ([gunchleoc](https://github.com/mastodon/mastodon/pull/26014), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26787)) -- Remove 16:9 cropping from web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26132)) -- Remove back button from bookmarks, favourites and lists screens in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26126)) -- Remove display name input from sign-up form ([Gargron](https://github.com/mastodon/mastodon/pull/24704)) -- Remove `tai` locale ([c960657](https://github.com/mastodon/mastodon/pull/23880)) -- Remove empty Kushubian (csb) local files ([nschonni](https://github.com/mastodon/mastodon/pull/24151)) -- Remove `Permissions-Policy` header from all responses ([Gargron](https://github.com/mastodon/mastodon/pull/24124)) - -### Fixed - -- **Fix filters not being applying in the explore page** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25887)) -- **Fix being unable to load past a full page of filtered posts in Home timeline** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24930)) -- **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073)) -- **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218)) -- **Fix migration handler not updating lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24808)) -- Fix crash when viewing a moderation appeal and the moderator account has been deleted ([xrobau](https://github.com/mastodon/mastodon/pull/25900)) -- Fix error in Web UI when server rules cannot be fetched ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26957)) -- Fix paragraph margins resulting in irregular read-more cut-off in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26828)) -- Fix notification permissions being requested immediately after login ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26472)) -- Fix performances of profile directory ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26840), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26842)) -- Fix mute button and volume slider feeling disconnected in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26827), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26860)) -- Fix “Scoped order is ignored, it's forced to be batch order.” warnings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26793)) -- Fix blocked domain appearing in account feeds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26823)) -- Fix invalid `Content-Type` header for WebP images ([c960657](https://github.com/mastodon/mastodon/pull/26773)) -- Fix minor inefficiencies in `tootctl search deploy` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26721)) -- Fix filter form in profiles directory overflowing instead of wrapping ([arbolitoloco1](https://github.com/mastodon/mastodon/pull/26682)) -- Fix sign up steps progress layout in right-to-left locales ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26728)) -- Fix bug with “favorited by” and “reblogged by“ view on posts only showing up to 40 items ([timothyjrogers](https://github.com/mastodon/mastodon/pull/26577), [timothyjrogers](https://github.com/mastodon/mastodon/pull/26574)) -- Fix bad search type heuristic ([Gargron](https://github.com/mastodon/mastodon/pull/26673)) -- Fix not being able to negate prefix clauses in search ([Gargron](https://github.com/mastodon/mastodon/pull/26672)) -- Fix timeout on invalid set of exclusionary parameters in `/api/v1/timelines/public` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/26239)) -- Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375)) -- Fix light theme select option for hashtags ([teeerevor](https://github.com/mastodon/mastodon/pull/26311)) -- Fix AVIF attachments ([c960657](https://github.com/mastodon/mastodon/pull/26264)) -- Fix incorrect URL normalization when fetching remote resources ([c960657](https://github.com/mastodon/mastodon/pull/26219), [c960657](https://github.com/mastodon/mastodon/pull/26285)) -- Fix being unable to filter posts for individual Chinese languages ([gunchleoc](https://github.com/mastodon/mastodon/pull/26066)) -- Fix preview card sometimes linking to 4xx error pages ([c960657](https://github.com/mastodon/mastodon/pull/26200)) -- Fix emoji picker button scrolling with textarea content in single-column view ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25304)) -- Fix missing border on error screen in light theme in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26152)) -- Fix UI overlap with the loupe icon in the Explore Tab ([gol-cha](https://github.com/mastodon/mastodon/pull/26113)) -- Fix unexpected redirection to `/explore` after sign-in ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26143)) -- Fix `/api/v1/statuses/:id/unfavourite` and `/api/v1/statuses/:id/unreblog` returning non-updated counts ([c960657](https://github.com/mastodon/mastodon/pull/24365)) -- Fix clicking the “Back” button sometimes leading out of Mastodon ([c960657](https://github.com/mastodon/mastodon/pull/23953), [CSFlorin](https://github.com/mastodon/mastodon/pull/24835), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/24867), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25281)) -- Fix processing of `null` ActivityPub activities ([tribela](https://github.com/mastodon/mastodon/pull/26021)) -- Fix hashtag posts not being removed from home feed on hashtag unfollow ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26028)) -- Fix for "follows you" indicator in light web UI not readable ([vmstan](https://github.com/mastodon/mastodon/pull/25993)) -- Fix incorrect line break between icon and number of reposts & favourites ([edent](https://github.com/mastodon/mastodon/pull/26004)) -- Fix sounds not being loaded from assets host ([Signez](https://github.com/mastodon/mastodon/pull/25931)) -- Fix buttons showing inconsistent styles ([teeerevor](https://github.com/mastodon/mastodon/pull/25903), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25965), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26482)) -- Fix trend calculation working on too many items at a time ([Gargron](https://github.com/mastodon/mastodon/pull/25835)) -- Fix dropdowns being disabled for logged out users in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25964)) -- Fix explore page being inaccessible when opted-out of trends in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25716)) -- Fix re-activated accounts possibly getting deleted by `AccountDeletionWorker` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25711)) -- Fix `/api/v2/search` not working with following query param ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25681)) -- Fix inefficient query when requesting a new confirmation email from a logged-in account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25669)) -- Fix unnecessary concurrent calls to `/api/*/instance` in web UI ([mgmn](https://github.com/mastodon/mastodon/pull/25663)) -- Fix resolving local URL for remote content ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25637)) -- Fix search not being easily findable on smaller screens in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25576), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25631)) -- Fix j/k keyboard shortcuts on some status lists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25554)) -- Fix missing validation on `default_privacy` setting ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25513)) -- Fix incorrect pagination headers in `/api/v2/admin/accounts` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25477)) -- Fix non-interactive upload container being given a `button` role and tabIndex ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25462)) -- Fix always redirecting to onboarding in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25396)) -- Fix inconsistent use of middle dot (·) instead of bullet (•) to separate items ([j-f1](https://github.com/mastodon/mastodon/pull/25248)) -- Fix spacing of middle dots in the detailed status meta section ([j-f1](https://github.com/mastodon/mastodon/pull/25247)) -- Fix prev/next buttons color in media viewer ([renchap](https://github.com/mastodon/mastodon/pull/25231)) -- Fix email addresses not being properly updated in `tootctl maintenance fix-duplicates` ([mjankowski](https://github.com/mastodon/mastodon/pull/25118)) -- Fix unicode surrogate pairs sometimes being broken in page title ([eai04191](https://github.com/mastodon/mastodon/pull/25148)) -- Fix various inefficient queries against account domains ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25126)) -- Fix video player offering to expand in a lightbox when it's in an `iframe` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25067)) -- Fix post embed previews ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25071)) -- Fix inadequate error handling in several API controllers when given invalid parameters ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24947), [danielmbrasil](https://github.com/mastodon/mastodon/pull/24958), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25063), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25072), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25386), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25595)) -- Fix uncaught `ActiveRecord::StatementInvalid` in Mastodon::IpBlocksCLI ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24861)) -- Fix various edge cases with local moves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24812)) -- Fix `tootctl accounts cull` crashing when encountering a domain resolving to a private address ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23378)) -- Fix `tootctl accounts approve --number N` not aproving the N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605)) -- Fix being unable to clear media description when editing posts ([c960657](https://github.com/mastodon/mastodon/pull/24720)) -- Fix unavailable translations not falling back to English ([mgmn](https://github.com/mastodon/mastodon/pull/24727)) -- Fix anonymous visitors getting a session cookie on first visit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24584), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24650), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24664)) -- Fix cutting off first letter of hashtag links sometimes in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24623)) -- Fix crash in `tootctl accounts create --reattach --force` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24557), [danielmbrasil](https://github.com/mastodon/mastodon/pull/24680)) -- Fix characters being emojified even when using Variation Selector 15 (text) ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20949), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24615)) -- Fix uncaught ActiveRecord::StatementInvalid exception in `Mastodon::AccountsCLI#approve` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24590)) -- Fix email confirmation skip option in `tootctl accounts modify USERNAME --email EMAIL --confirm` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24578)) -- Fix tooltip for dates without time ([c960657](https://github.com/mastodon/mastodon/pull/24244)) -- Fix missing loading spinner and loading more on scroll in Private Mentions column ([c960657](https://github.com/mastodon/mastodon/pull/24446)) -- Fix account header image missing from `/settings/profile` on narrow screens ([c960657](https://github.com/mastodon/mastodon/pull/24433)) -- Fix height of announcements not being updated when using reduced animations ([c960657](https://github.com/mastodon/mastodon/pull/24354)) -- Fix inconsistent radius in advanced interface drawer ([thislight](https://github.com/mastodon/mastodon/pull/24407)) -- Fix loading more trending posts on scroll in the advanced interface ([OmmyZhang](https://github.com/mastodon/mastodon/pull/24314)) -- Fix poll ending notification for edited polls ([c960657](https://github.com/mastodon/mastodon/pull/24311)) -- Fix max width of media in `/about` and `/privacy-policy` ([mgmn](https://github.com/mastodon/mastodon/pull/24180)) -- Fix streaming API not being usable without `DATABASE_URL` ([Gargron](https://github.com/mastodon/mastodon/pull/23960)) -- Fix external authentication not running onboarding code for new users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23458)) - -## [4.1.8] - 2023-09-19 - -### Fixed - -- Fix post edits not being forwarded as expected ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26936)) -- Fix moderator rights inconsistencies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26729)) -- Fix crash when encountering invalid URL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26814)) -- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409)) -- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500)) -- Fix unexpected audio stream transcoding when uploaded video is eligible to passthrough ([yufushiro](https://github.com/mastodon/mastodon/pull/26608)) - -### Security - -- Fix missing HTML sanitization in translation API (CVE-2023-42452, [GHSA-2693-xr3m-jhqr](https://github.com/mastodon/mastodon/security/advisories/GHSA-2693-xr3m-jhqr)) -- Fix incorrect domain name normalization (CVE-2023-42451, [GHSA-v3xf-c9qf-j667](https://github.com/mastodon/mastodon/security/advisories/GHSA-v3xf-c9qf-j667)) - -## [4.1.7] - 2023-09-05 - -### Changed - -- Change remote report processing to accept reports with long comments, but truncate them ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25028)) - -### Fixed - -- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392)) -- Fix `/api/v1/timelines/tag/:hashtag` allowing for unauthenticated access when public preview is disabled ([danielmbrasil](https://github.com/mastodon/mastodon/pull/26237)) -- Fix inefficiencies in `PlainTextFormatter` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26727)) - -## [4.1.6] - 2023-07-31 - -### Fixed - -- Fix memory leak in streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26228)) -- Fix wrong filters sometimes applying in streaming ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26159), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26213), [renchap](https://github.com/mastodon/mastodon/pull/26233)) -- Fix incorrect connect timeout in outgoing requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26116)) - -## [4.1.5] - 2023-07-21 - -### Added - -- Add check preventing Sidekiq workers from running with Makara configured ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25850)) - -### Changed - -- Change request timeout handling to use a longer deadline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26055)) - -### Fixed - -- Fix moderation interface for remote instances with a .zip TLD ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25885)) -- Fix remote accounts being possibly persisted to database with incomplete protocol values ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25886)) -- Fix trending publishers table not rendering correctly on narrow screens ([vmstan](https://github.com/mastodon/mastodon/pull/25945)) - -### Security - -- Fix CSP headers being unintentionally wide ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26105)) - -## [4.1.4] - 2023-07-07 - -### Fixed - -- Fix branding:generate_app_icons failing because of disallowed ICO coder ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25794)) -- Fix crash in admin interface when viewing a remote user with verified links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25796)) -- Fix processing of media files with unusual names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25788)) - -## [4.1.3] - 2023-07-06 - -### Added - -- Add fallback redirection when getting a webfinger query `LOCAL_DOMAIN@LOCAL_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23600)) - -### Changed - -- Change OpenGraph-based embeds to allow fullscreen ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25058)) -- Change AccessTokensVacuum to also delete expired tokens ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868)) -- Change profile updates to be sent to recently-mentioned servers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24852)) -- Change automatic post deletion thresholds and load detection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24614)) -- Change `/api/v1/statuses/:id/history` to always return at least one item ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25510)) -- Change auto-linking to allow carets in URL query params ([renchap](https://github.com/mastodon/mastodon/pull/25216)) - -### Removed - -- Remove invalid `X-Frame-Options: ALLOWALL` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25070)) - -### Fixed - -- Fix wrong view being displayed when a webhook fails validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25464)) -- Fix soft-deleted post cleanup scheduler overwhelming the streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25519)) -- Fix incorrect pagination headers in `/api/v2/admin/accounts` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25477)) -- Fix multiple inefficiencies in automatic post cleanup worker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24785), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24840)) -- Fix performance of streaming by parsing message JSON once ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25278), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25361)) -- Fix CSP headers when `S3_ALIAS_HOST` includes a path component ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25273)) -- Fix `tootctl accounts approve --number N` not approving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605)) -- Fix reports not being closed when performing batch suspensions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24988)) -- Fix being able to vote on your own polls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25015)) -- Fix race condition when reblogging a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25016)) -- Fix “Authorized applications” inefficiently and incorrectly getting last use date ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25060)) -- Fix “Authorized applications” crashing when listing apps with certain admin API scopes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25713)) -- Fix multiple N+1s in ConversationsController ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25134), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25399), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25499)) -- Fix user archive takeouts when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24431)) -- Fix searching for remote content by URL not working under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25637)) -- Fix inefficiencies in indexing content for search ([VyrCossont](https://github.com/mastodon/mastodon/pull/24285), [VyrCossont](https://github.com/mastodon/mastodon/pull/24342)) - -### Security - -- Add finer permission requirements for managing webhooks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25463)) -- Update dependencies -- Add hardening headers for user-uploaded files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25756)) -- Fix verified links possibly hiding important parts of the URL (CVE-2023-36462) -- Fix timeout handling of outbound HTTP requests (CVE-2023-36461) -- Fix arbitrary file creation through media processing (CVE-2023-36460) -- Fix possible XSS in preview cards (CVE-2023-36459) - -## [4.1.2] - 2023-04-04 - -### Fixed - -- Fix crash in `tootctl` commands making use of parallelization when Elasticsearch is enabled ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24182), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24377)) -- Fix crash in `db:setup` when Elasticsearch is enabled ([rrgeorge](https://github.com/mastodon/mastodon/pull/24302)) -- Fix user archive takeout when using OpenStack Swift or S3 providers with no ACL support ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24200)) -- Fix invalid/expired invites being processed on sign-up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24337)) - -### Security - -- Update Ruby to 3.0.6 due to ReDoS vulnerabilities ([saizai](https://github.com/mastodon/mastodon/pull/24334)) -- Fix unescaped user input in LDAP query ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24379)) - -## [4.1.1] - 2023-03-16 - -### Added - -- Add redirection from paths with url-encoded `@` to their decoded form ([thijskh](https://github.com/mastodon/mastodon/pull/23593)) -- Add `lang` attribute to native language names in language picker in Web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23749)) -- Add headers to outgoing mails to avoid auto-replies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23597)) -- Add support for refreshing many accounts at once with `tootctl accounts refresh` ([9p4](https://github.com/mastodon/mastodon/pull/23304)) -- Add confirmation modal when clicking to edit a post with a non-empty compose form ([PauloVilarinho](https://github.com/mastodon/mastodon/pull/23936)) -- Add support for the HAproxy PROXY protocol through the `PROXY_PROTO_V1` environment variable ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24064)) -- Add `SENDFILE_HEADER` environment variable ([Gargron](https://github.com/mastodon/mastodon/pull/24123)) -- Add cache headers to static files served through Rails ([Gargron](https://github.com/mastodon/mastodon/pull/24120)) - -### Changed - -- Increase contrast of upload progress bar background ([toolmantim](https://github.com/mastodon/mastodon/pull/23836)) -- Change post auto-deletion throttling constants to better scale with server size ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23320)) -- Change order of bookmark and favourite sidebar entries in single-column UI for consistency ([TerryGarcia](https://github.com/mastodon/mastodon/pull/23701)) -- Change `ActivityPub::DeliveryWorker` retries to be spread out more ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21956)) - -### Fixed - -- Fix “Remove all followers from the selected domains” also removing follows and notifications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23805)) -- Fix streaming metrics format ([emilweth](https://github.com/mastodon/mastodon/pull/23519), [emilweth](https://github.com/mastodon/mastodon/pull/23520)) -- Fix case-sensitive check for previously used hashtags in hashtag autocompletion ([deanveloper](https://github.com/mastodon/mastodon/pull/23526)) -- Fix focus point of already-attached media not saving after edit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23566)) -- Fix sidebar behavior in settings/admin UI on mobile ([wxt2005](https://github.com/mastodon/mastodon/pull/23764)) -- Fix inefficiency when searching accounts per username in admin interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23801)) -- Fix duplicate “Publish” button on mobile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23804)) -- Fix server error when failing to follow back followers from `/relationships` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23787)) -- Fix server error when attempting to display the edit history of a trendable post in the admin interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23574)) -- Fix `tootctl accounts migrate` crashing because of a typo ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23567)) -- Fix original account being unfollowed on migration before the follow request to the new account could be sent ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21957)) -- Fix the “Back” button in column headers sometimes leaving Mastodon ([c960657](https://github.com/mastodon/mastodon/pull/23953)) -- Fix pgBouncer resetting application name on every transaction ([Gargron](https://github.com/mastodon/mastodon/pull/23958)) -- Fix unconfirmed accounts being counted as active users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23803)) -- Fix `/api/v1/streaming` sub-paths not being redirected ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23988)) -- Fix drag'n'drop upload area text that spans multiple lines not being centered ([vintprox](https://github.com/mastodon/mastodon/pull/24029)) -- Fix sidekiq jobs not triggering Elasticsearch index updates ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24046)) -- Fix tags being unnecessarily stripped from plain-text short site description ([c960657](https://github.com/mastodon/mastodon/pull/23975)) -- Fix HTML entities not being un-escaped in extracted plain-text from remote posts ([c960657](https://github.com/mastodon/mastodon/pull/24019)) -- Fix dashboard crash on ElasticSearch server error ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23751)) -- Fix incorrect post links in strikes when the account is remote ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23611)) -- Fix misleading error code when receiving invalid WebAuthn credentials ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23568)) -- Fix duplicate mails being sent when the SMTP server is too slow to close the connection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23750)) - -### Security - -- Change user backups to use expiring URLs for download when possible ([Gargron](https://github.com/mastodon/mastodon/pull/24136)) -- Add warning for object storage misconfiguration ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24137)) - -## [4.1.0] - 2023-02-10 - -### Added - -- **Add support for importing/exporting server-wide domain blocks** ([enbylenore](https://github.com/mastodon/mastodon/pull/20597), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/21471), [dariusk](https://github.com/mastodon/mastodon/pull/22803), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/21470)) -- **Add listing of followed hashtags** ([connorshea](https://github.com/mastodon/mastodon/pull/21773)) -- **Add support for editing media description and focus point of already-sent posts** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20878)) - - Previously, you could add and remove attachments, but not edit media description of already-attached media - - REST API changes: - - `PUT /api/v1/statuses/:id` now takes an extra `media_attributes[]` array parameter with the `id` of the updated media and their updated `description`, `focus`, and `thumbnail` -- **Add follow request banner on account header** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20785)) - - REST API changes: - - `Relationship` entities have an extra `requested_by` boolean attribute representing whether the represented user has requested to follow you -- **Add confirmation screen when handling reports** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22375), [Gargron](https://github.com/mastodon/mastodon/pull/23156), [tribela](https://github.com/mastodon/mastodon/pull/23178)) -- Add option to make the landing page be `/about` even when trends are enabled ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20808)) -- Add `noindex` setting back to the admin interface ([prplecake](https://github.com/mastodon/mastodon/pull/22205)) -- Add instance peers API endpoint toggle back to the admin interface ([dariusk](https://github.com/mastodon/mastodon/pull/22810)) -- Add instance activity API endpoint toggle back to the admin interface ([dariusk](https://github.com/mastodon/mastodon/pull/22833)) -- Add setting for status page URL ([Gargron](https://github.com/mastodon/mastodon/pull/23390), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23499)) - - REST API changes: - - Add `configuration.urls.status` attribute to the object returned by `GET /api/v2/instance` -- Add `account.approved` webhook ([Saiv46](https://github.com/mastodon/mastodon/pull/22938)) -- Add 12 hours option to polls ([Pleclown](https://github.com/mastodon/mastodon/pull/21131)) -- Add dropdown menu item to open admin interface for remote domains ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21895)) -- Add `--remove-headers`, `--prune-profiles` and `--include-follows` flags to `tootctl media remove` ([evanphilip](https://github.com/mastodon/mastodon/pull/22149)) -- Add `--email` and `--dry-run` options to `tootctl accounts delete` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22328)) -- Add `tootctl accounts migrate` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22330)) -- Add `tootctl accounts prune` ([tribela](https://github.com/mastodon/mastodon/pull/18397)) -- Add `tootctl domains purge` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22063)) -- Add `SIDEKIQ_CONCURRENCY` environment variable ([muffinista](https://github.com/mastodon/mastodon/pull/19589)) -- Add `DB_POOL` environment variable support for streaming server ([Gargron](https://github.com/mastodon/mastodon/pull/23470)) -- Add `MIN_THREADS` environment variable to set minimum Puma threads ([jimeh](https://github.com/mastodon/mastodon/pull/21048)) -- Add explanation text to log-in page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20946)) -- Add user profile OpenGraph tag on post pages ([bramus](https://github.com/mastodon/mastodon/pull/21423)) -- Add maskable icon support for Android ([workeffortwaste](https://github.com/mastodon/mastodon/pull/20904)) -- Add Belarusian to supported languages ([Mixaill](https://github.com/mastodon/mastodon/pull/22022)) -- Add Western Frisian to supported languages ([ykzts](https://github.com/mastodon/mastodon/pull/18602)) -- Add Montenegrin to the language picker ([ayefries](https://github.com/mastodon/mastodon/pull/21013)) -- Add Southern Sami and Lule Sami to the language picker ([Jullan-M](https://github.com/mastodon/mastodon/pull/21262)) -- Add logging for Rails cache timeouts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21667)) -- Add color highlight for active hashtag “follow” button ([MFTabriz](https://github.com/mastodon/mastodon/pull/21629)) -- Add brotli compression to `assets:precompile` ([Izorkin](https://github.com/mastodon/mastodon/pull/19025)) -- Add “disabled” account filter to the `/admin/accounts` UI ([tribela](https://github.com/mastodon/mastodon/pull/21282)) -- Add transparency to modal background for accessibility ([edent](https://github.com/mastodon/mastodon/pull/18081)) -- Add `lang` attribute to image description textarea and poll option field ([c960657](https://github.com/mastodon/mastodon/pull/23293)) -- Add `spellcheck` attribute to Content Warning and poll option input fields ([c960657](https://github.com/mastodon/mastodon/pull/23395)) -- Add `title` attribute to video elements in media attachments ([bramus](https://github.com/mastodon/mastodon/pull/21420)) -- Add left and right margins to emojis ([dsblank](https://github.com/mastodon/mastodon/pull/20464)) -- Add `roles` attribute to `Account` entities in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23255), [tribela](https://github.com/mastodon/mastodon/pull/23428)) -- Add `reading:autoplay:gifs` to `/api/v1/preferences` ([j-f1](https://github.com/mastodon/mastodon/pull/22706)) -- Add `hide_collections` parameter to `/api/v1/accounts/credentials` ([CarlSchwan](https://github.com/mastodon/mastodon/pull/22790)) -- Add `policy` attribute to web push subscription objects in REST API at `/api/v1/push/subscriptions` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23210)) -- Add metrics endpoint to streaming API ([Gargron](https://github.com/mastodon/mastodon/pull/23388), [Gargron](https://github.com/mastodon/mastodon/pull/23469)) -- Add more specific error messages to HTTP signature verification ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21617)) -- Add Storj DCS to cloud object storage options in the `mastodon:setup` rake task ([jtolio](https://github.com/mastodon/mastodon/pull/21929)) -- Add checkmark symbol in the checkbox for sensitive media ([sidp](https://github.com/mastodon/mastodon/pull/22795)) -- Add missing accessibility attributes to logout link in modals ([kytta](https://github.com/mastodon/mastodon/pull/22549)) -- Add missing accessibility attributes to “Hide image” button in `MediaGallery` ([hs4man21](https://github.com/mastodon/mastodon/pull/22513)) -- Add missing accessibility attributes to hide content warning field when disabled ([hs4man21](https://github.com/mastodon/mastodon/pull/22568)) -- Add `aria-hidden` to footer circle dividers to improve accessibility ([hs4man21](https://github.com/mastodon/mastodon/pull/22576)) -- Add `lang` attribute to compose form inputs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23240)) - -### Changed - -- **Ensure exact match is the first result in hashtag searches** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21315)) -- Change account search to return followed accounts first ([dariusk](https://github.com/mastodon/mastodon/pull/22956)) -- Change batch account suspension to create a strike ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20897)) -- Change default reply language to match the default language when replying to a translated post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22272)) -- Change misleading wording about waitlists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20850)) -- Increase width of the unread notification border ([connorshea](https://github.com/mastodon/mastodon/pull/21692)) -- Change new post notification button on profiles to make it more apparent when it is enabled ([tribela](https://github.com/mastodon/mastodon/pull/22541)) -- Change trending tags admin interface to always show batch action controls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23013)) -- Change wording of some OAuth scope descriptions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22491)) -- Change wording of admin report handling actions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18388)) -- Change confirm prompts for relationships management ([tribela](https://github.com/mastodon/mastodon/pull/19411)) -- Change language surrounding disability in prompts for media descriptions ([hs4man21](https://github.com/mastodon/mastodon/pull/20923)) -- Change confusing wording in the sign in banner ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22490)) -- Change `POST /settings/applications/:id` to regenerate token on scopes change ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23359)) -- Change account moderation notes to make links clickable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22553)) -- Change link previews for statuses to never use avatar as fallback ([Gargron](https://github.com/mastodon/mastodon/pull/23376)) -- Change email address input to be read-only for logged-in users when requesting a new confirmation e-mail ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23247)) -- Change notifications per page from 15 to 40 in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/23348)) -- Change number of stored items in home feed from 400 to 800 ([Gargron](https://github.com/mastodon/mastodon/pull/23349)) -- Change API rate limits from 300/5min per user to 1500/5min per user, 300/5min per app ([Gargron](https://github.com/mastodon/mastodon/pull/23347)) -- Save avatar or header correctly even if the other one fails ([tribela](https://github.com/mastodon/mastodon/pull/18465)) -- Change `referrer-policy` to `same-origin` application-wide ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23014), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23037)) -- Add 'private' to `Cache-Control`, match Rails expectations ([daxtens](https://github.com/mastodon/mastodon/pull/20608)) -- Make the button that expands the compose form differentiable from the button that publishes a post ([Tak](https://github.com/mastodon/mastodon/pull/20864)) -- Change automatic post deletion configuration to be accessible to moved users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20774)) -- Make tag following idempotent ([trwnh](https://github.com/mastodon/mastodon/pull/20860), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/21285)) -- Use buildx functions for faster builds ([inductor](https://github.com/mastodon/mastodon/pull/20692)) -- Split off Dockerfile components for faster builds ([moritzheiber](https://github.com/mastodon/mastodon/pull/20933), [ineffyble](https://github.com/mastodon/mastodon/pull/20948), [BtbN](https://github.com/mastodon/mastodon/pull/21028)) -- Change last occurrence of “silence” to “limit” in UI text ([cincodenada](https://github.com/mastodon/mastodon/pull/20637)) -- Change “hide toot” to “hide post” ([seanthegeek](https://github.com/mastodon/mastodon/pull/22385)) -- Don't allow URLs that contain non-normalized paths to be verified ([dgl](https://github.com/mastodon/mastodon/pull/20999)) -- Change the “Trending now” header to be a link to the Explore page ([connorshea](https://github.com/mastodon/mastodon/pull/21759)) -- Change PostgreSQL connection timeout from 2 minutes to 15 seconds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21790)) -- Make handle more easily selectable on profile page ([cadars](https://github.com/mastodon/mastodon/pull/21479)) -- Allow admins to refresh remotely-suspended accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22327)) -- Change dropdown menu to contain “Copy link to post” even for non-public posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21316)) -- Allow adding relays in secure mode and limited federation mode ([ineffyble](https://github.com/mastodon/mastodon/pull/22324)) -- Change timestamps to be displayed using the user's timezone throughout the moderation interface ([FrancisMurillo](https://github.com/mastodon/mastodon/pull/21878), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/22555)) -- Change CSP directives on API to be tight and concise ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20960)) -- Change web UI to not autofocus the compose form ([raboof](https://github.com/mastodon/mastodon/pull/16517), [Akkiesoft](https://github.com/mastodon/mastodon/pull/23094)) -- Change idempotency key handling for posting when database access is slow ([lambda](https://github.com/mastodon/mastodon/pull/21840)) -- Change remote media files to be downloaded outside of transactions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21796)) -- Improve contrast of charts in “poll has ended” notifications ([j-f1](https://github.com/mastodon/mastodon/pull/22575)) -- Change OEmbed detection and validation to be somewhat more lenient ([ineffyble](https://github.com/mastodon/mastodon/pull/22533)) -- Widen ElasticSearch version detection to not display a warning for OpenSearch ([VyrCossont](https://github.com/mastodon/mastodon/pull/22422), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23064)) -- Change link verification to allow pages larger than 1MB as long as the link is in the first 1MB ([untitaker](https://github.com/mastodon/mastodon/pull/22879)) -- Update default Node.js version to Node.js 16 ([ineffyble](https://github.com/mastodon/mastodon/pull/22223), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/22342)) - -### Removed - -- Officially remove support for Ruby 2.6 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21477)) -- Remove `object-fit` polyfill used for old versions of Microsoft Edge ([shuuji3](https://github.com/mastodon/mastodon/pull/22693)) -- Remove `intersection-observer` polyfill for old Safari support ([shuuji3](https://github.com/mastodon/mastodon/pull/23284)) -- Remove empty `title` tag from mailer layout ([nametoolong](https://github.com/mastodon/mastodon/pull/23078)) -- Remove post count and last posts from ActivityPub representation of hashtag collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23460)) - -### Fixed - -- **Fix changing domain block severity not undoing individual account effects** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22135)) -- Fix suspension worker crashing on S3-compatible setups without ACL support ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22487)) -- Fix possible race conditions when suspending/unsuspending accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22363)) -- Fix being stuck in edit mode when deleting the edited posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22126)) -- Fix attached media uploads not being cleared when replying to a post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23504)) -- Fix filters not being applied to some notification types ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23211)) -- Fix incorrect link in push notifications for some event types ([elizabeth-dev](https://github.com/mastodon/mastodon/pull/23286)) -- Fix some performance issues with `/admin/instances` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21907)) -- Fix some pre-4.0 admin audit logs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22091)) -- Fix moderation audit log items for warnings having incorrect links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23242)) -- Fix account activation being sometimes triggered before email confirmation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23245)) -- Fix missing OAuth scopes for admin APIs ([trwnh](https://github.com/mastodon/mastodon/pull/20918), [trwnh](https://github.com/mastodon/mastodon/pull/20979)) -- Fix voter count not being cleared when a poll is reset ([afontenot](https://github.com/mastodon/mastodon/pull/21700)) -- Fix attachments of edited posts not being fetched ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21565)) -- Fix irreversible and whole_word parameters handling in `/api/v1/filters` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21988)) -- Fix 500 error when marking posts as sensitive while some of them are deleted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22134)) -- Fix expanded posts not always being scrolled into view ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21797)) -- Fix not being able to scroll the remote interaction modal on small screens ([xendke](https://github.com/mastodon/mastodon/pull/21763)) -- Fix not being able to scroll in post history modal ([cadars](https://github.com/mastodon/mastodon/pull/23396)) -- Fix audio player volume control on Safari ([minacle](https://github.com/mastodon/mastodon/pull/23187)) -- Fix disappearing “Explore” tabs on Safari ([nyura](https://github.com/mastodon/mastodon/pull/20917), [ykzts](https://github.com/mastodon/mastodon/pull/20982)) -- Fix wrong padding in RTL layout ([Gargron](https://github.com/mastodon/mastodon/pull/23157)) -- Fix drag & drop upload area display in single-column mode ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23217)) -- Fix being unable to get a single EmailDomainBlock from the admin API ([trwnh](https://github.com/mastodon/mastodon/pull/20846)) -- Fix admin-set follow recommandations being case-sensitive ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23500)) -- Fix unserialized `role` on account entities in admin API ([Gargron](https://github.com/mastodon/mastodon/pull/23290)) -- Fix pagination of followed tags ([trwnh](https://github.com/mastodon/mastodon/pull/20861)) -- Fix dropdown menu positions when scrolling ([sidp](https://github.com/mastodon/mastodon/pull/22916), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23062)) -- Fix email with empty domain name labels passing validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23246)) -- Fix mysterious registration failure when “Require a reason to join” is set with open registrations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22127)) -- Fix attachment rendering of edited posts in OpenGraph ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22270)) -- Fix invalid/empty RSS feed link on account pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20772)) -- Fix error in `VerifyLinkService` when processing links with no href ([joshuap](https://github.com/mastodon/mastodon/pull/20741)) -- Fix error in `VerifyLinkService` when processing links with invalid URLs ([untitaker](https://github.com/mastodon/mastodon/pull/23204)) -- Fix media uploads with FFmpeg 5 ([dead10ck](https://github.com/mastodon/mastodon/pull/21191)) -- Fix sensitive flag not being set when replying to a post with a content warning under certain conditions ([kedamaDQ](https://github.com/mastodon/mastodon/pull/21724)) -- Fix misleading message briefly showing up when loading follow requests under some conditions ([c960657](https://github.com/mastodon/mastodon/pull/23386)) -- Fix “Share @:user's profile” profile menu item not working ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21490)) -- Fix crash and incorrect behavior in `tootctl domains crawl` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19004)) -- Fix autoplay on iOS ([jamesadney](https://github.com/mastodon/mastodon/pull/21422)) -- Fix user clean-up scheduler crash when an unconfirmed account has a moderation note ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23318)) -- Fix spaces not being stripped in admin account search ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21324)) -- Fix spaces not being stripped when adding relays ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22655)) -- Fix infinite loading spinner instead of soft 404 for non-existing remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21303)) -- Fix minor visual issue with the top border of verified account fields ([j-f1](https://github.com/mastodon/mastodon/pull/22006)) -- Fix pending account approval and rejection not being recorded in the admin audit log ([FrancisMurillo](https://github.com/mastodon/mastodon/pull/22088)) -- Fix “Sign up” button with closed registrations not opening modal on mobile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22060)) -- Fix UI header overflowing on mobile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21783)) -- Fix 500 error when trying to migrate to an invalid address ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21462)) -- Fix crash when trying to fetch unobtainable avatar of user using external authentication ([lochiiconnectivity](https://github.com/mastodon/mastodon/pull/22462)) -- Fix processing error on incoming malformed JSON-LD under some situations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23416)) -- Fix potential duplicate posts in Explore tab ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22121)) -- Fix deprecation warning in `tootctl accounts rotate` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22120)) -- Fix styling of featured tags in light theme ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23252)) -- Fix missing style in warning and strike cards ([AtelierSnek](https://github.com/mastodon/mastodon/pull/22177), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/22302)) -- Fix wasteful request to `/api/v1/custom_emojis` when not logged in ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22326)) -- Fix replies sometimes being delivered to user-blocked domains ([tribela](https://github.com/mastodon/mastodon/pull/22117)) -- Fix admin dashboard crash when using some ElasticSearch replacements ([cortices](https://github.com/mastodon/mastodon/pull/21006)) -- Fix profile avatar being slightly offset into left border ([RiedleroD](https://github.com/mastodon/mastodon/pull/20994)) -- Fix N+1 queries in `NotificationsController` ([nametoolong](https://github.com/mastodon/mastodon/pull/21202)) -- Fix being unable to react to announcements with the keycap number sign emoji ([kescherCode](https://github.com/mastodon/mastodon/pull/22231)) -- Fix height computation of post embeds ([hodgesmr](https://github.com/mastodon/mastodon/pull/22141)) -- Fix accessibility issue of the search bar due to hidden placeholder ([alexstine](https://github.com/mastodon/mastodon/pull/21275)) -- Fix layout change handler not being removed due to a typo ([nschonni](https://github.com/mastodon/mastodon/pull/21829)) -- Fix typo in the default `S3_HOSTNAME` used in the `mastodon:setup` rake task ([danp](https://github.com/mastodon/mastodon/pull/19932)) -- Fix the top action bar appearing in the multi-column layout ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20943)) -- Fix inability to use local LibreTranslate without setting `ALLOWED_PRIVATE_ADDRESSES` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21926)) -- Fix punycoded local domains not being prettified in initial state ([Tritlo](https://github.com/mastodon/mastodon/pull/21440)) -- Fix CSP violation warning by removing inline CSS from SVG logo ([luxiaba](https://github.com/mastodon/mastodon/pull/20814)) -- Fix margin for search field on medium window size ([minacle](https://github.com/mastodon/mastodon/pull/21606)) -- Fix search popout scrolling with the page in single-column mode ([rgroothuijsen](https://github.com/mastodon/mastodon/pull/16463)) -- Fix minor post cache hydration discrepancy ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19879)) -- Fix `・` detection in hashtags ([parthoghosh24](https://github.com/mastodon/mastodon/pull/22888)) -- Fix hashtag follows bypassing user blocks ([tribela](https://github.com/mastodon/mastodon/pull/22849)) -- Fix moved accounts being incorrectly redirected to account settings when trying to view a remote profile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22497)) -- Fix site upload validations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22479)) -- Fix “Add new domain block” button using last submitted search value instead of the current one ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22485)) -- Fix misleading hashtag warning when posting with “Followers only” or “Mentioned people only” visibility ([n0toose](https://github.com/mastodon/mastodon/pull/22827)) -- Fix embedded posts with videos grabbing focus ([Akkiesoft](https://github.com/mastodon/mastodon/pull/22778)) -- Fix `$` not being escaped in `.env.production` files generated by the `mastodon:setup` rake task ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23012), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23072)) -- Fix sanitizer parsing link text as HTML when stripping unsupported links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22558)) -- Fix `scheduled_at` input not using `datetime-local` when editing announcements ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21896)) -- Fix REST API serializer for `Account` not including `moved` when the moved account has itself moved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22483)) -- Fix `/api/v1/admin/trends/tags` using wrong serializer ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18943)) -- Fix situations in which instance actor can be set to a Mastodon-incompatible name ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22307)) - -### Security - -- Add `form-action` CSP directive ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20781), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20958), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20962)) -- Fix unbounded recursion in account discovery ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22025)) -- Revoke all authorized applications on password reset ([FrancisMurillo](https://github.com/mastodon/mastodon/pull/21325)) -- Fix unbounded recursion in post discovery ([ClearlyClaire,nametoolong](https://github.com/mastodon/mastodon/pull/23506)) - -## [4.0.2] - 2022-11-15 - -### Fixed - -- Fix wrong color on mentions hidden behind content warning in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/20724)) -- Fix filters from other users being used in the streaming service ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20719)) -- Fix `unsafe-eval` being used when `wasm-unsafe-eval` is enough in Content Security Policy ([Gargron](https://github.com/mastodon/mastodon/pull/20729), [prplecake](https://github.com/mastodon/mastodon/pull/20606)) - -## [4.0.1] - 2022-11-14 - -### Fixed - -- Fix nodes order being sometimes mangled when rewriting emoji ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20677)) - -## [4.0.0] - 2022-11-14 - -Some of the features in this release have been funded through the [NGI0 Discovery](https://nlnet.nl/discovery) Fund, a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 825322. - -### Added - -- Add ability to filter followed accounts' posts by language ([Gargron](https://github.com/mastodon/mastodon/pull/19095), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19268)) -- **Add ability to follow hashtags** ([Gargron](https://github.com/mastodon/mastodon/pull/18809), [Gargron](https://github.com/mastodon/mastodon/pull/18862), [Gargron](https://github.com/mastodon/mastodon/pull/19472), [noellabo](https://github.com/mastodon/mastodon/pull/18924)) -- Add ability to filter individual posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18945)) -- **Add ability to translate posts** ([Gargron](https://github.com/mastodon/mastodon/pull/19218), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19433), [Gargron](https://github.com/mastodon/mastodon/pull/19453), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19434), [Gargron](https://github.com/mastodon/mastodon/pull/19388), [ykzts](https://github.com/mastodon/mastodon/pull/19244), [Gargron](https://github.com/mastodon/mastodon/pull/19245)) -- Add featured tags to web UI ([noellabo](https://github.com/mastodon/mastodon/pull/19408), [noellabo](https://github.com/mastodon/mastodon/pull/19380), [noellabo](https://github.com/mastodon/mastodon/pull/19358), [noellabo](https://github.com/mastodon/mastodon/pull/19409), [Gargron](https://github.com/mastodon/mastodon/pull/19382), [ykzts](https://github.com/mastodon/mastodon/pull/19418), [noellabo](https://github.com/mastodon/mastodon/pull/19403), [noellabo](https://github.com/mastodon/mastodon/pull/19404), [Gargron](https://github.com/mastodon/mastodon/pull/19398), [Gargron](https://github.com/mastodon/mastodon/pull/19712), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20018)) -- **Add support for language preferences for trending statuses and links** ([Gargron](https://github.com/mastodon/mastodon/pull/18288), [Gargron](https://github.com/mastodon/mastodon/pull/19349), [ykzts](https://github.com/mastodon/mastodon/pull/19335)) - - Previously, you could only see trends in your current language - - For less popular languages, that meant empty trends - - Now, trends in your preferred languages' are shown on top, with others beneath -- Add server rules to sign-up flow ([Gargron](https://github.com/mastodon/mastodon/pull/19296)) -- Add privacy icons to report modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19190)) -- Add `noopener` to links to remote profiles in web UI ([shleeable](https://github.com/mastodon/mastodon/pull/19014)) -- Add option to open original page in dropdowns of remote content in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/20299)) -- Add warning for sensitive audio posts in web UI ([rgroothuijsen](https://github.com/mastodon/mastodon/pull/17885)) -- Add language attribute to posts in web UI ([tribela](https://github.com/mastodon/mastodon/pull/18544)) -- Add support for uploading WebP files ([Saiv46](https://github.com/mastodon/mastodon/pull/18506)) -- Add support for uploading `audio/vnd.wave` files ([tribela](https://github.com/mastodon/mastodon/pull/18737)) -- Add support for uploading AVIF files ([txt-file](https://github.com/mastodon/mastodon/pull/19647)) -- Add support for uploading HEIC files ([Gargron](https://github.com/mastodon/mastodon/pull/19618)) -- Add more debug information when processing remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15605), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19209)) -- **Add retention policy for cached content and media** ([Gargron](https://github.com/mastodon/mastodon/pull/19232), [zunda](https://github.com/mastodon/mastodon/pull/19478), [Gargron](https://github.com/mastodon/mastodon/pull/19458), [Gargron](https://github.com/mastodon/mastodon/pull/19248)) - - Set for how long remote posts or media should be cached on your server - - Hands-off alternative to `tootctl` commands -- **Add customizable user roles** ([Gargron](https://github.com/mastodon/mastodon/pull/18641), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18812), [Gargron](https://github.com/mastodon/mastodon/pull/19040), [tribela](https://github.com/mastodon/mastodon/pull/18825), [tribela](https://github.com/mastodon/mastodon/pull/18826), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18776), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18777), [unextro](https://github.com/mastodon/mastodon/pull/18786), [tribela](https://github.com/mastodon/mastodon/pull/18824), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19436)) - - Previously, there were 3 hard-coded roles, user, moderator, and admin - - Create your own roles and decide which permissions they should have -- Add notifications for new reports ([Gargron](https://github.com/mastodon/mastodon/pull/18697), [Gargron](https://github.com/mastodon/mastodon/pull/19475)) -- Add ability to select all accounts matching search for batch actions in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/19053), [Gargron](https://github.com/mastodon/mastodon/pull/19054)) -- Add ability to view previous edits of a status in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/19462)) -- Add ability to block sign-ups from IP ([Gargron](https://github.com/mastodon/mastodon/pull/19037)) -- **Add webhooks to admin UI** ([Gargron](https://github.com/mastodon/mastodon/pull/18510)) -- Add admin API for managing domain allows ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18668)) -- Add admin API for managing domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18247)) -- Add admin API for managing e-mail domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19066)) -- Add admin API for managing canonical e-mail blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19067)) -- Add admin API for managing IP blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19065), [trwnh](https://github.com/mastodon/mastodon/pull/20207)) -- Add `sensitized` attribute to accounts in admin REST API ([trwnh](https://github.com/mastodon/mastodon/pull/20094)) -- Add `services` and `metadata` to the NodeInfo endpoint ([MFTabriz](https://github.com/mastodon/mastodon/pull/18563)) -- Add `--remove-role` option to `tootctl accounts modify` ([Gargron](https://github.com/mastodon/mastodon/pull/19477)) -- Add `--days` option to `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/18425)) -- Add `EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18642)) -- Add `IP_RETENTION_PERIOD` and `SESSION_RETENTION_PERIOD` environment variables ([kescherCode](https://github.com/mastodon/mastodon/pull/18757)) -- Add `http_hidden_proxy` environment variable ([tribela](https://github.com/mastodon/mastodon/pull/18427)) -- Add `ENABLE_STARTTLS` environment variable ([erbridge](https://github.com/mastodon/mastodon/pull/20321)) -- Add caching for payload serialization during fan-out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19637), [Gargron](https://github.com/mastodon/mastodon/pull/19642), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19746), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19747), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19963)) -- Add assets from Twemoji 14.0 ([Gargron](https://github.com/mastodon/mastodon/pull/19733)) -- Add reputation and followers score boost to SQL-only account search ([Gargron](https://github.com/mastodon/mastodon/pull/19251)) -- Add Scots, Balaibalan, Láadan, Lingua Franca Nova, Lojban, Toki Pona to languages list ([VyrCossont](https://github.com/mastodon/mastodon/pull/20168)) -- Set autocomplete hints for e-mail, password and OTP fields ([rcombs](https://github.com/mastodon/mastodon/pull/19833), [offbyone](https://github.com/mastodon/mastodon/pull/19946), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20071)) -- Add support for DigitalOcean Spaces in setup wizard ([v-aisac](https://github.com/mastodon/mastodon/pull/20573)) - -### Changed - -- **Change brand color and logotypes** ([Gargron](https://github.com/mastodon/mastodon/pull/18592), [Gargron](https://github.com/mastodon/mastodon/pull/18639), [Gargron](https://github.com/mastodon/mastodon/pull/18691), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18634), [Gargron](https://github.com/mastodon/mastodon/pull/19254), [mayaeh](https://github.com/mastodon/mastodon/pull/18710)) -- **Change post editing to be enabled in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/19103)) -- **Change web UI to work for logged-out users** ([Gargron](https://github.com/mastodon/mastodon/pull/18961), [Gargron](https://github.com/mastodon/mastodon/pull/19250), [Gargron](https://github.com/mastodon/mastodon/pull/19294), [Gargron](https://github.com/mastodon/mastodon/pull/19306), [Gargron](https://github.com/mastodon/mastodon/pull/19315), [ykzts](https://github.com/mastodon/mastodon/pull/19322), [Gargron](https://github.com/mastodon/mastodon/pull/19412), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19437), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19415), [Gargron](https://github.com/mastodon/mastodon/pull/19348), [Gargron](https://github.com/mastodon/mastodon/pull/19295), [Gargron](https://github.com/mastodon/mastodon/pull/19422), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19414), [Gargron](https://github.com/mastodon/mastodon/pull/19319), [Gargron](https://github.com/mastodon/mastodon/pull/19345), [Gargron](https://github.com/mastodon/mastodon/pull/19310), [Gargron](https://github.com/mastodon/mastodon/pull/19301), [Gargron](https://github.com/mastodon/mastodon/pull/19423), [ykzts](https://github.com/mastodon/mastodon/pull/19471), [ykzts](https://github.com/mastodon/mastodon/pull/19333), [ykzts](https://github.com/mastodon/mastodon/pull/19337), [ykzts](https://github.com/mastodon/mastodon/pull/19272), [ykzts](https://github.com/mastodon/mastodon/pull/19468), [Gargron](https://github.com/mastodon/mastodon/pull/19466), [Gargron](https://github.com/mastodon/mastodon/pull/19457), [Gargron](https://github.com/mastodon/mastodon/pull/19426), [Gargron](https://github.com/mastodon/mastodon/pull/19427), [Gargron](https://github.com/mastodon/mastodon/pull/19421), [Gargron](https://github.com/mastodon/mastodon/pull/19417), [Gargron](https://github.com/mastodon/mastodon/pull/19413), [Gargron](https://github.com/mastodon/mastodon/pull/19397), [Gargron](https://github.com/mastodon/mastodon/pull/19387), [Gargron](https://github.com/mastodon/mastodon/pull/19396), [Gargron](https://github.com/mastodon/mastodon/pull/19385), [ykzts](https://github.com/mastodon/mastodon/pull/19334), [ykzts](https://github.com/mastodon/mastodon/pull/19329), [Gargron](https://github.com/mastodon/mastodon/pull/19324), [Gargron](https://github.com/mastodon/mastodon/pull/19318), [Gargron](https://github.com/mastodon/mastodon/pull/19316), [Gargron](https://github.com/mastodon/mastodon/pull/19263), [trwnh](https://github.com/mastodon/mastodon/pull/19305), [ykzts](https://github.com/mastodon/mastodon/pull/19273), [Gargron](https://github.com/mastodon/mastodon/pull/19801), [Gargron](https://github.com/mastodon/mastodon/pull/19790), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19773), [Gargron](https://github.com/mastodon/mastodon/pull/19798), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19724), [Gargron](https://github.com/mastodon/mastodon/pull/19709), [Gargron](https://github.com/mastodon/mastodon/pull/19514), [Gargron](https://github.com/mastodon/mastodon/pull/19562), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19981), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19978), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20148), [Gargron](https://github.com/mastodon/mastodon/pull/20302), [cutls](https://github.com/mastodon/mastodon/pull/20400)) - - The web app can now be accessed without being logged in - - No more `/web` prefix on web app paths - - Profiles, posts, and other public pages now use the same interface for logged in and logged out users - - The web app displays a server information banner - - Pop-up windows for remote interaction have been replaced with a modal window - - No need to type in your username for remote interaction, copy-paste-to-search method explained - - Various hints throughout the app explain what the different timelines are - - New about page design - - New privacy policy page design shows when the policy was last updated - - All sections of the web app now have appropriate window titles - - The layout of the interface has been streamlined between different screen sizes - - Posts now use more horizontal space -- Change label of publish button to be "Publish" again in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18583)) -- Change language to be carried over on reply in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18557)) -- Change "Unfollow" to "Cancel follow request" when request still pending in web UI ([prplecake](https://github.com/mastodon/mastodon/pull/19363)) -- **Change post filtering system** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18058), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19050), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18894), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19051), [noellabo](https://github.com/mastodon/mastodon/pull/18923), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18744), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19878), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20567)) - - Filtered keywords and phrases can now be grouped into named categories - - Filtered posts show which exact filter was hit - - Individual posts can be added to a filter - - You can peek inside filtered posts anyway -- Change path of privacy policy page from `/terms` to `/privacy-policy` ([Gargron](https://github.com/mastodon/mastodon/pull/19249)) -- Change how hashtags are normalized ([Gargron](https://github.com/mastodon/mastodon/pull/18795), [Gargron](https://github.com/mastodon/mastodon/pull/18863), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18854)) -- Change settings area to be separated into categories in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/19407), [Gargron](https://github.com/mastodon/mastodon/pull/19533)) -- Change "No accounts selected" errors to use the appropriate noun in admin UI ([prplecake](https://github.com/mastodon/mastodon/pull/19356)) -- Change e-mail domain blocks to match subdomains of blocked domains ([Gargron](https://github.com/mastodon/mastodon/pull/18979)) -- Change custom emoji file size limit from 50 KB to 256 KB ([Gargron](https://github.com/mastodon/mastodon/pull/18788)) -- Change "Allow trends without prior review" setting to also work for trending posts ([Gargron](https://github.com/mastodon/mastodon/pull/17977)) -- Change admin announcements form to use single inputs for date and time in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18321)) -- Change search API to be accessible without being logged in ([Gargron](https://github.com/mastodon/mastodon/pull/18963), [Gargron](https://github.com/mastodon/mastodon/pull/19326)) -- Change following and followers API to be accessible without being logged in ([Gargron](https://github.com/mastodon/mastodon/pull/18964)) -- Change `AUTHORIZED_FETCH` to not block unauthenticated REST API access ([Gargron](https://github.com/mastodon/mastodon/pull/19803)) -- Change Helm configuration ([deepy](https://github.com/mastodon/mastodon/pull/18997), [jgsmith](https://github.com/mastodon/mastodon/pull/18415), [deepy](https://github.com/mastodon/mastodon/pull/18941)) -- Change mentions of blocked users to not be processed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19725)) -- Change max. thumbnail dimensions to 640x360px (360p) ([Gargron](https://github.com/mastodon/mastodon/pull/19619)) -- Change post-processing to be deferred only for large media types ([Gargron](https://github.com/mastodon/mastodon/pull/19617)) -- Change link verification to only work for https links without unicode ([Gargron](https://github.com/mastodon/mastodon/pull/20304), [Gargron](https://github.com/mastodon/mastodon/pull/20295)) -- Change account deletion requests to spread out over time ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20222)) -- Change larger reblogs/favourites numbers to be shortened in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/20303)) -- Change incoming activity processing to happen in `ingress` queue ([Gargron](https://github.com/mastodon/mastodon/pull/20264)) -- Change notifications to not link show preview cards in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20335)) -- Change amount of replies returned for logged out users in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20355)) -- Change in-app links to keep you in-app in web UI ([trwnh](https://github.com/mastodon/mastodon/pull/20540), [Gargron](https://github.com/mastodon/mastodon/pull/20628)) -- Change table header to be sticky in admin UI ([sk22](https://github.com/mastodon/mastodon/pull/20442)) - -### Removed - -- Remove setting that disables account deletes ([Gargron](https://github.com/mastodon/mastodon/pull/17683)) -- Remove digest e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/17985)) -- Remove unnecessary sections from welcome e-mail ([Gargron](https://github.com/mastodon/mastodon/pull/19299)) -- Remove item titles from RSS feeds ([Gargron](https://github.com/mastodon/mastodon/pull/18640)) -- Remove volume number from hashtags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19253)) -- Remove Nanobox configuration ([tonyjiang](https://github.com/mastodon/mastodon/pull/17881)) - -### Fixed - -- Fix rules with same priority being sorted non-deterministically ([Gargron](https://github.com/mastodon/mastodon/pull/20623)) -- Fix error when invalid domain name is submitted ([Gargron](https://github.com/mastodon/mastodon/pull/19474)) -- Fix icons having an image role ([Gargron](https://github.com/mastodon/mastodon/pull/20600)) -- Fix connections to IPv6-only servers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20108)) -- Fix unnecessary service worker registration and preloading when logged out in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20341)) -- Fix unnecessary and slow regex construction ([raggi](https://github.com/mastodon/mastodon/pull/20215)) -- Fix `mailers` queue not being used for mailers ([Gargron](https://github.com/mastodon/mastodon/pull/20274)) -- Fix error in webfinger redirect handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20260)) -- Fix report category not being set to `violation` if rule IDs are provided ([trwnh](https://github.com/mastodon/mastodon/pull/20137)) -- Fix nodeinfo metadata attribute being an array instead of an object ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20114)) -- Fix account endorsements not being idempotent ([trwnh](https://github.com/mastodon/mastodon/pull/20118)) -- Fix status and rule IDs not being strings in admin reports REST API ([trwnh](https://github.com/mastodon/mastodon/pull/20122)) -- Fix error on invalid `replies_policy` in REST API ([trwnh](https://github.com/mastodon/mastodon/pull/20126)) -- Fix redrafting a currently-editing post not leaving edit mode in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20023)) -- Fix performance by avoiding method cache busts ([raggi](https://github.com/mastodon/mastodon/pull/19957)) -- Fix opening the language picker scrolling the single-column view to the top in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19983)) -- Fix content warning button missing `aria-expanded` attribute in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19975)) -- Fix redundant `aria-pressed` attributes in web UI ([Brawaru](https://github.com/mastodon/mastodon/pull/19912)) -- Fix crash when external auth provider has no display name set ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19962)) -- Fix followers count not being updated when migrating follows ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19998)) -- Fix double button to clear emoji search input in web UI ([sunny](https://github.com/mastodon/mastodon/pull/19888)) -- Fix missing null check on applications on strike disputes ([kescherCode](https://github.com/mastodon/mastodon/pull/19851)) -- Fix featured tags not saving preferred casing ([Gargron](https://github.com/mastodon/mastodon/pull/19732)) -- Fix language not being saved when editing status ([Gargron](https://github.com/mastodon/mastodon/pull/19543)) -- Fix not being able to input featured tag with hash symbol ([Gargron](https://github.com/mastodon/mastodon/pull/19535)) -- Fix user clean-up scheduler crash when an unconfirmed account has a moderation note ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19629)) -- Fix being unable to withdraw follow request when confirmation modal is disabled in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19687)) -- Fix inaccurate admin log entry for re-sending confirmation e-mails ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19674)) -- Fix edits not being immediately reflected ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19673)) -- Fix bookmark import stopping at the first failure ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19669)) -- Fix account action type validation ([Gargron](https://github.com/mastodon/mastodon/pull/19476)) -- Fix upload progress not communicating processing phase in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19530)) -- Fix wrong host being used for custom.css when asset host configured ([Gargron](https://github.com/mastodon/mastodon/pull/19521)) -- Fix account migration form ever using outdated account data ([Gargron](https://github.com/mastodon/mastodon/pull/18429), [nightpool](https://github.com/mastodon/mastodon/pull/19883)) -- Fix error when uploading malformed CSV import ([Gargron](https://github.com/mastodon/mastodon/pull/19509)) -- Fix avatars not using image tags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19488)) -- Fix handling of duplicate and out-of-order notifications in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19693)) -- Fix reblogs being discarded after the reblogged status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19731)) -- Fix indexing scheduler trying to index when Elasticsearch is disabled ([Gargron](https://github.com/mastodon/mastodon/pull/19805)) -- Fix n+1 queries when rendering initial state JSON ([Gargron](https://github.com/mastodon/mastodon/pull/19795)) -- Fix n+1 query during status removal ([Gargron](https://github.com/mastodon/mastodon/pull/19753)) -- Fix OCR not working due to Content Security Policy in web UI ([prplecake](https://github.com/mastodon/mastodon/pull/18817)) -- Fix `nofollow` rel being removed in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19455)) -- Fix language dropdown causing zoom on mobile devices in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19428)) -- Fix button to dismiss suggestions not showing up in search results in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19325)) -- Fix language dropdown sometimes not appearing in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19246)) -- Fix quickly switching notification filters resulting in empty or incorrect list in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19052), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18960)) -- Fix media modal link button in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18877)) -- Fix error upon successful account migration ([Gargron](https://github.com/mastodon/mastodon/pull/19386)) -- Fix negatives values in search index causing queries to fail ([Gargron](https://github.com/mastodon/mastodon/pull/19464), [Gargron](https://github.com/mastodon/mastodon/pull/19481)) -- Fix error when searching for invalid URL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18580)) -- Fix IP blocks not having a unique index ([Gargron](https://github.com/mastodon/mastodon/pull/19456)) -- Fix remote account in contact account setting not being used ([Gargron](https://github.com/mastodon/mastodon/pull/19351)) -- Fix swallowing mentions of unconfirmed/unapproved users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19191)) -- Fix incorrect and slow cache invalidation when blocking domain and removing media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19062)) -- Fix HTTPs redirect behaviour when running as I2P service ([gi-yt](https://github.com/mastodon/mastodon/pull/18929)) -- Fix deleted pinned posts potentially counting towards the pinned posts limit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19005)) -- Fix compatibility with OpenSSL 3.0 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18449)) -- Fix error when a remote report includes a private post the server has no access to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18760)) -- Fix suspicious sign-in mails never being sent ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18599)) -- Fix fallback locale when somehow user's locale is an empty string ([tribela](https://github.com/mastodon/mastodon/pull/18543)) -- Fix avatar/header not being deleted locally when deleted on remote account ([tribela](https://github.com/mastodon/mastodon/pull/18973)) -- Fix missing `,` in Blurhash validation ([noellabo](https://github.com/mastodon/mastodon/pull/18660)) -- Fix order by most recent not working for relationships page in admin UI ([tribela](https://github.com/mastodon/mastodon/pull/18996)) -- Fix uncaught error when invalid date is supplied to API ([Gargron](https://github.com/mastodon/mastodon/pull/19480)) -- Fix REST API sometimes returning HTML on error ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19135)) -- Fix ambiguous column names in `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/19206)) -- Fix ambiguous column names in `tootctl search deploy` ([mashirozx](https://github.com/mastodon/mastodon/pull/18993)) -- Fix `CDN_HOST` not being used in some asset URLs ([tribela](https://github.com/mastodon/mastodon/pull/18662)) -- Fix `CAS_DISPLAY_NAME`, `SAML_DISPLAY_NAME` and `OIDC_DISPLAY_NAME` being ignored ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18568)) -- Fix various typos in comments throughout the codebase ([luzpaz](https://github.com/mastodon/mastodon/pull/18604)) -- Fix CSV import error when rows include unicode characters ([HamptonMakes](https://github.com/mastodon/mastodon/pull/20592)) - -### Security - -- Fix being able to spoof link verification ([Gargron](https://github.com/mastodon/mastodon/pull/20217)) -- Fix emoji substitution not applying only to text nodes in backend code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20641)) -- Fix emoji substitution not applying only to text nodes in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20640)) -- Fix rate limiting for paths with formats ([Gargron](https://github.com/mastodon/mastodon/pull/20675)) -- Fix out-of-bound reads in blurhash transcoder ([delroth](https://github.com/mastodon/mastodon/pull/20388)) - -_For previous changes, review the [stable-3.5 branch](https://github.com/mastodon/mastodon/blob/stable-3.5/CHANGELOG.md)_ +_For previous changes, review the [stable-4.3 branch](https://github.com/mastodon/mastodon/blob/stable-4.3/CHANGELOG.md)_ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 2ee2e538bc48bb..9669c601adea57 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,46 +1,83 @@ -# Contributor Covenant Code of Conduct +# Contributor Covenant 3.0 Code of Conduct ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +We pledge to make our community welcoming, safe, and equitable for all. -## Our Standards +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant. -Examples of behavior that contributes to creating a positive environment include: +## Encouraged Behaviors -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language. -Examples of unacceptable behavior by participants include: +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including: -- The use of sexualized language or imagery and unwelcome sexual attention or advances -- Trolling, insulting/derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or electronic address, without explicit permission -- Other conduct which could reasonably be considered inappropriate in a professional setting +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. -## Our Responsibilities +## Restricted Behaviors -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. -## Scope +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors. + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, send an email describing the situation to glitch-abuse@sitedethib.com. -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution. -## Enforcement +## Addressing and Repairing Harm -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at glitch-abuse@sitedethib.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +1. Warning + 1. Event: A violation involving a single incident or series of incidents. + 2. Consequence: A private, written warning from the Community Moderators. + 3. Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations. +2. Temporarily Limited Activities + 1. Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation. + 2. Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members. + 3. Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over. +3. Temporary Suspension + 1. Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation. + 2. Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3. Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4. Permanent Ban + 1. Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member. + 2. Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior. + 3. Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) -[homepage]: https://contributor-covenant.org -[version]: https://contributor-covenant.org/version/1/4/ +For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89fd3c7995fa1d..27980889eccf59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,8 @@ You can contribute in the following ways: Please review the org-level [contribution guidelines] for high-level acceptance criteria guidance and the [DEVELOPMENT] guide for environment-specific details. +You should also read the project's [AI Contribution Policy] to understand how we approach +AI-assisted contributions. ## API Changes and Additions @@ -80,7 +82,7 @@ reviewed and merged into the codebase. Our time is limited and PRs making large, unsolicited changes are unlikely to get a response. Changes which link to an existing confirmed issue, or which come -from a "help wanted" issue or other request are more likely to be reviewed. +from a "help wanted" issue or other request, are more likely to be reviewed. The smaller and more narrowly focused the changes in a PR are, the easier they are to review and potentially merge. If the change only makes sense in some @@ -130,3 +132,4 @@ and API docs. Improvements are made via PRs to the [documentation repository]. [keepachangelog]: https://keepachangelog.com/en/1.0.0/ [Mastodon documentation]: https://docs.joinmastodon.org [SECURITY]: SECURITY.md +[AI Contribution Policy]: https://github.com/mastodon/.github/blob/main/AI_POLICY.md diff --git a/Dockerfile b/Dockerfile index f2164ffd94102a..c95f356937d4fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,12 @@ ARG TARGETPLATFORM=${TARGETPLATFORM} ARG BUILDPLATFORM=${BUILDPLATFORM} ARG BASE_REGISTRY="docker.io" -# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] +# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="4.0.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.4.6" -# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] +ARG RUBY_VERSION="4.0.5" +# # 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="22" +ARG NODE_MAJOR_VERSION="24" # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"] ARG DEBIAN_VERSION="trixie" # Node.js image to use for base image based on combined variables (ex: 20-trixie-slim) @@ -25,8 +25,8 @@ FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA -# Example: v4.3.0-nightly.2023.11.09+pr-123456 -# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] +# Example: v4.3.0-nightly.2023-11-09+pr-123456 +# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023-11-09"] ARG MASTODON_VERSION_PRERELEASE="" # Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"] ARG MASTODON_VERSION_METADATA="" @@ -48,31 +48,27 @@ ARG GID="991" # Apply Mastodon build options based on options above ENV \ - # Apply Mastodon version information MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \ SOURCE_COMMIT="${SOURCE_COMMIT}" \ - # Apply Mastodon static files and YJIT options - RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \ - RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \ - # Apply timezone - TZ=${TZ} - + RAILS_SERVE_STATIC_FILES="${RAILS_SERVE_STATIC_FILES}" \ + RUBY_YJIT_ENABLE="${RUBY_YJIT_ENABLE}" \ + TZ="${TZ}" + +# Configure runtime environment +# BIND: IP to bind Mastodon to when serving traffic +# NODE_ENV/RAILS_ENV: production settings for Node.js and Ruby on Rails +# DEBIAN_FRONTEND: suppress interactive prompts +# PATH: add Ruby and Mastodon installation directories +# MALLOC_CONF: optimize jemalloc 5.x performance +# MASTODON_SIDEKIQ_READY_FILENAME: Sidekiq readiness check filename for Kubernetes ENV \ - # Configure the IP to bind Mastodon to when serving traffic BIND="0.0.0.0" \ - # Use production settings for Yarn, Node.js and related tools NODE_ENV="production" \ - # Use production settings for Ruby on Rails RAILS_ENV="production" \ - # Add Ruby and Mastodon installation to the PATH DEBIAN_FRONTEND="noninteractive" \ 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 # Set default shell used for running commands @@ -101,10 +97,10 @@ RUN \ # Mount Apt cache and lib directories from Docker buildx caches --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ - # Apt update & upgrade to check for security updates to Debian image + # Update package list and upgrade system packages apt-get update; \ apt-get dist-upgrade -yq; \ - # Install jemalloc, curl and other necessary components + # Install jemalloc and other necessary components apt-get install -y --no-install-recommends \ curl \ file \ @@ -114,6 +110,42 @@ RUN \ tini \ tzdata \ wget \ + # Mastodon components + libexpat1 \ + libglib2.0-0t64 \ + libicu76 \ + libidn12 \ + libpq5 \ + libreadline8t64 \ + libssl3t64 \ + libyaml-0-2 \ + # libvips components + libcgif0 \ + libexif12 \ + libheif1 \ + libhwy1t64 \ + libimagequant0 \ + libjpeg62-turbo \ + liblcms2-2 \ + libspng0 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ + # ffmpeg components + libdav1d7 \ + libmp3lame0 \ + libopencore-amrnb0 \ + libopencore-amrwb0 \ + libopus0 \ + libsnappy1v5 \ + libtheora0 \ + libvorbis0a \ + libvorbisenc2 \ + libvorbisfile3 \ + libvpx9 \ + libx264-164 \ + libx265-215 \ ; \ # Patch Ruby to use jemalloc patchelf --add-needed libjemalloc.so.2 /usr/local/bin/ruby; \ @@ -122,42 +154,37 @@ RUN \ patchelf \ ; -# Create temporary build layer from base image -FROM ruby AS build +# Build stage for media libraries (libvips, ffmpeg) +FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS media-build ARG TARGETPLATFORM +# Set default shell used for running commands +SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"] + # hadolint ignore=DL3008 RUN \ - # Mount Apt cache and lib directories from Docker buildx caches - --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ - # Install build tools and bundler dependencies from APT + --mount=type=cache,id=apt-native-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-native-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Remove automatic apt cache Docker cleanup scripts + rm -f /etc/apt/apt.conf.d/docker-clean; \ + # Install build tools for native libraries + apt-get update; \ apt-get install -y --no-install-recommends \ autoconf \ automake \ build-essential \ - cmake \ - git \ - libgdbm-dev \ - libglib2.0-dev \ - libgmp-dev \ - libicu-dev \ - libidn-dev \ - libpq-dev \ - libssl-dev \ libtool \ - libyaml-dev \ meson \ nasm \ pkg-config \ - shared-mime-info \ xz-utils \ # libvips components libcgif-dev \ libexif-dev \ libexpat1-dev \ libgirepository1.0-dev \ + libglib2.0-dev \ libheif-dev \ libhwy-dev \ libimagequant-dev \ @@ -178,12 +205,12 @@ RUN \ libx265-dev \ ; -# Create temporary libvips specific build layer from build layer -FROM build AS libvips +# Create temporary libvips specific build layer +FROM media-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.2 +ARG VIPS_VERSION=8.18.3 # 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 @@ -194,26 +221,27 @@ RUN tar xf vips-${VIPS_VERSION}.tar.xz; WORKDIR /usr/local/libvips/src/vips-${VIPS_VERSION} -# Configure and compile libvips -RUN \ - meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false; \ - cd build; \ - ninja; \ - ninja install; +# Configure libvips +RUN meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false -# Create temporary ffmpeg specific build layer from build layer -FROM build AS ffmpeg +WORKDIR /usr/local/libvips/src/vips-${VIPS_VERSION}/build + +# Compile and install libvips +RUN ninja && ninja install + +# Create temporary ffmpeg specific build layer +FROM media-build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] -# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg -ARG FFMPEG_VERSION=8.0 +# renovate: datasource=github-tags depName=FFmpeg/FFmpeg extractVersion=^n(?\d+\.\d+(\.\d+)?)$ +ARG FFMPEG_VERSION=8.1.2 # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"] -ARG FFMPEG_URL=https://ffmpeg.org/releases +ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags WORKDIR /usr/local/ffmpeg/src # Download and extract ffmpeg source code -ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/ -RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; +ADD ${FFMPEG_URL}/n${FFMPEG_VERSION}.tar.gz /usr/local/ffmpeg/src/ +RUN tar xf n${FFMPEG_VERSION}.tar.gz && mv FFmpeg-n${FFMPEG_VERSION} ffmpeg-${FFMPEG_VERSION}; WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION} @@ -243,17 +271,48 @@ RUN \ --enable-shared \ --enable-version3 \ ; \ - make -j$(nproc); \ + make -j"$(nproc)"; \ make install; +# Create temporary build layer from base image for Ruby dependencies +FROM ruby AS ruby-build + +ARG TARGETPLATFORM + +# hadolint ignore=DL3008 +RUN \ + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Install build tools and bundler dependencies from APT + apt-get install -y --no-install-recommends \ + build-essential \ + git \ + libgdbm-dev \ + libgmp-dev \ + libicu-dev \ + libidn-dev \ + libpq-dev \ + libssl-dev \ + libyaml-dev \ + shared-mime-info \ + zlib1g-dev \ + ; + # Create temporary bundler specific build layer from build layer -FROM build AS bundler +FROM ruby-build AS bundler ARG TARGETPLATFORM # Copy Gemfile config into working directory COPY Gemfile* /opt/mastodon/ +# Copy libvips for gems that need it during install +COPY --from=libvips /usr/local/libvips/lib /usr/local/lib +COPY --from=libvips /usr/local/libvips/include /usr/local/include + +RUN ldconfig + RUN \ # Mount Ruby Gem caches --mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ @@ -269,7 +328,7 @@ RUN \ bundle install -j"$(nproc)"; # Create temporary assets build layer from build layer -FROM build AS precompiler +FROM ruby-build AS precompiler ARG TARGETPLATFORM @@ -281,10 +340,13 @@ COPY --from=node /usr/local/bin /usr/local/bin COPY --from=node /usr/local/lib /usr/local/lib RUN \ - # Configure Corepack - rm /usr/local/bin/yarn*; \ - corepack enable; \ - corepack prepare --activate; + # Mount local Corepack and Yarn caches from Docker buildx caches + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Remove pre-installed Yarn binaries (only present on Node <26) + rm -f /usr/local/bin/yarn*; \ + # Install Corepack + npm i -g corepack; # hadolint ignore=DL3008 RUN \ @@ -313,53 +375,6 @@ FROM ruby AS mastodon ARG TARGETPLATFORM -# hadolint ignore=DL3008 -RUN \ - # Mount Apt cache and lib directories from Docker buildx caches - --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ - # Mount Corepack and Yarn caches from Docker buildx caches - --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ - --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ - # Apt update install non-dev versions of necessary components - apt-get install -y --no-install-recommends \ - libexpat1 \ - libglib2.0-0t64 \ - libicu76 \ - libidn12 \ - libpq5 \ - libreadline8t64 \ - libssl3t64 \ - libyaml-0-2 \ - # libvips components - libcgif0 \ - libexif12 \ - libheif1 \ - libhwy1t64 \ - libimagequant0 \ - libjpeg62-turbo \ - liblcms2-2 \ - libspng0 \ - libtiff6 \ - libwebp7 \ - libwebpdemux2 \ - libwebpmux3 \ - # ffmpeg components - libdav1d7 \ - libmp3lame0 \ - libopencore-amrnb0 \ - libopencore-amrwb0 \ - libopus0 \ - libsnappy1v5 \ - libtheora0 \ - libvorbis0a \ - libvorbisenc2 \ - libvorbisfile3 \ - libvpx9 \ - libx264-164 \ - libx265-215 \ - ; - # Copy Mastodon sources into final layer COPY . /opt/mastodon/ diff --git a/FEDERATION.md b/FEDERATION.md index 03ea5449de340b..22bbb03a0c5a59 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -13,7 +13,8 @@ - [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) - [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) - [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) -- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md): partial support for incoming quote-posts +- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) +- [FEP-3b86: Activity Intents](https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md): offer handlers for `Object` and `Create` (with support for the `content` parameter only), has support for the `Follow`, `Announce`, `Like` and `Object` intents ## ActivityPub in Mastodon @@ -48,3 +49,25 @@ 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`) | 10000 | Description will be truncated | +| Collection name (`FeaturedCollection` `name`) | 256 | Name will be truncated | +| Collection description (`FeaturedCollection` `summary`) | 2048 | Description will be truncated | diff --git a/Gemfile b/Gemfile index 126d73f9cab1e3..57ad858c1e9dd5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,19 +1,19 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 3.2.0', '< 3.5.0' +ruby '>= 3.3.0', '< 4.1.0' gem 'propshaft' -gem 'puma', '~> 7.0' -gem 'rails', '~> 8.0' +gem 'puma' +gem 'rails', '~> 8.1.0' gem 'thor', '~> 1.2' gem 'dotenv' -gem 'haml-rails', '~>2.0' +gem 'haml-rails', '~>3.0' gem 'pg', '~> 1.5' gem 'pghero' -gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873 +gem 'aws-sdk-core', require: false gem 'aws-sdk-s3', '~> 1.123', require: false gem 'blurhash', '~> 0.1' gem 'fog-core', '<= 2.6.0' @@ -24,11 +24,11 @@ gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.18.0', require: false +gem 'bootsnap', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' -gem 'chewy', '~> 7.3' -gem 'devise', '~> 4.9' +gem 'chewy' +gem 'devise' gem 'devise-two-factor' group :pam_authentication, optional: true do @@ -40,25 +40,25 @@ 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' gem 'csv', '~> 3.2' -gem 'discard', '~> 1.2' +gem 'discard', '~> 2.0' gem 'doorkeeper', '~> 5.6' gem 'faraday-httpclient' gem 'fast_blank', '~> 1.0' gem 'fastimage' -gem 'hiredis', '~> 0.6' 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' +gem 'ipaddr', '~> 1.2' gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' @@ -67,17 +67,16 @@ gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar' gem 'mutex_m' gem 'nokogiri', '~> 1.15' -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' gem 'rails-i18n', '~> 8.0' gem 'redcarpet', '~> 3.6' -gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] +gem 'redis', '~> 5' gem 'rqrcode', '~> 3.0' gem 'ruby-progressbar', '~> 1.13' gem 'sanitize', '~> 7.0' @@ -96,29 +95,30 @@ gem 'tzinfo-data', '~> 1.2023' gem 'webauthn', '~> 3.0' gem 'webpush', github: 'mastodon/webpush', ref: '9631ac63045cfabddacc69fc06e919b4c13eb913' +gem 'json' gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' gem 'prometheus_exporter', '~> 2.2', require: false -gem 'opentelemetry-api', '~> 1.7.0' +gem 'opentelemetry-api', '~> 1.10.0' group :opentelemetry do - gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false - gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false - gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false - gem 'opentelemetry-instrumentation-excon', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false - gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false - gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-net_http', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false - gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false + gem 'opentelemetry-exporter-otlp', '~> 0.34.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.13.0', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.33.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.36.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.42.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.29.0', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false end @@ -129,16 +129,13 @@ group :test do # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab gem 'rspec-github', '~> 3.0', require: false - # RSpec helpers for email specs - gem 'email_spec' - # Extra RSpec extension methods and helpers for sidekiq gem 'rspec-sidekiq', '~> 5.0' # Browser integration testing gem 'capybara', '~> 3.39' gem 'capybara-playwright-driver' - gem 'playwright-ruby-client', '1.55.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.60.0', 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' @@ -160,6 +157,9 @@ group :test do # Stub web requests for specs gem 'webmock', '~> 3.18' + + # Websocket driver for testing integration between rails/sidekiq and streaming + gem 'websocket-driver', '~> 0.8', require: false end group :development do @@ -177,14 +177,14 @@ group :development do # Enhanced error message pages for development gem 'better_errors', '~> 2.9' - gem 'binding_of_caller', '~> 1.0' + gem 'binding_of_caller' # Preview mail in the browser gem 'letter_opener', '~> 1.8' 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 @@ -223,11 +223,13 @@ gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' -gem 'net-http', '~> 0.6.0' +gem 'net-http', '~> 0.9.0' gem 'rubyzip', '~> 3.0' gem 'hcaptcha', '~> 7.1' gem 'mail', '~> 2.8' -gem 'vite_rails', '~> 3.0.19' +gem 'vite_rails' + +gem 'base58', '~> 0.2.3' diff --git a/Gemfile.lock b/Gemfile.lock index 64ef3057d8a664..69654af40c5ed2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,31 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (8.0.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + action_text-trix (2.1.19) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2.1) - actionpack (= 8.0.2.1) - activejob (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) - actionmailer (8.0.2.1) - actionpack (= 8.0.2.1) - actionview (= 8.0.2.1) - activejob (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2.1) - actionview (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,100 +42,105 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2.1) - actionpack (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2.1) - activesupport (= 8.0.2.1) + actionview (8.1.3) + activesupport (= 8.1.3) builder (~> 3.1) 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) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (8.0.2.1) - activesupport (= 8.0.2.1) + activejob (8.1.3) + activesupport (= 8.1.3) globalid (>= 0.3.6) - activemodel (8.0.2.1) - activesupport (= 8.0.2.1) - activerecord (8.0.2.1) - activemodel (= 8.0.2.1) - activesupport (= 8.0.2.1) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) timeout (>= 0.4.0) - activestorage (8.0.2.1) - actionpack (= 8.0.2.1) - activejob (= 8.0.2.1) - activerecord (= 8.0.2.1) - activesupport (= 8.0.2.1) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) marcel (~> 1.0) - activesupport (8.0.2.1) + activesupport (8.1.3) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) 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.9.0) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotaterb (4.19.0) + annotaterb (4.22.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.1135.0) - aws-sdk-core (3.215.1) + aws-partitions (1.1261.0) + aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.177.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.226.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) azure-blob (0.5.9.1) rexml + base58 (0.2.3) base64 (0.3.0) bcp47_spec (0.2.1) - bcrypt (3.1.20) - benchmark (0.4.1) + bcrypt (3.1.22) + benchmark (0.5.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.2.3) + bigdecimal (3.3.1) bindata (2.5.1) - binding_of_caller (1.0.1) + binding_of_caller (2.0.0) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.18.6) + bootsnap (1.24.6) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (8.0.5) 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 @@ -144,189 +151,189 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-playwright-driver (0.5.7) + capybara-playwright-driver (0.5.9) addressable capybara playwright-ruby-client (>= 1.16.0) case_transform (0.2) activesupport - cbor (0.5.9.8) - cgi (0.4.2) + cbor (0.5.10.2) + cgi (0.5.1) charlock_holmes (0.7.9) - chewy (7.6.0) - activesupport (>= 5.2) - elasticsearch (>= 7.14.0, < 8) + chewy (8.3.1) + activesupport (>= 7.2) + elasticsearch (>= 8.14, < 9.0) elasticsearch-dsl childprocess (5.1.0) logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) cocoon (1.2.15) - color_diff (0.1) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + color_diff (0.2) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml crass (1.0.6) - css_parser (1.21.1) + css_parser (3.0.0) addressable + ssrf_filter (~> 1.5) csv (3.3.5) database_cleaner-active_record (2.2.2) activerecord (>= 5.a) database_cleaner-core (~> 2.0) - database_cleaner-core (2.0.1) - date (3.4.1) - debug (1.11.0) + database_cleaner-core (2.1.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.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0) + railties (>= 7.0) responders warden (~> 1.2.3) - devise-two-factor (6.1.0) - activesupport (>= 7.0, < 8.1) - devise (~> 4.0) - railties (>= 7.0, < 8.1) + 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) rpam2 (~> 4.0) diff-lcs (1.6.2) - discard (1.4.0) - activerecord (>= 4.2, < 9.0) + discard (2.0.0) + activerecord (>= 7.0, < 9.0) docile (1.4.1) domain_name (0.6.20240107) - doorkeeper (5.8.2) + doorkeeper (5.9.3) railties (>= 5) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) - dry-cli (1.2.0) - elasticsearch (7.17.11) - elasticsearch-api (= 7.17.11) - elasticsearch-transport (= 7.17.11) - elasticsearch-api (7.17.11) + dry-cli (1.4.1) + elastic-transport (8.5.2) + faraday (< 3) multi_json - elasticsearch-dsl (0.1.10) - elasticsearch-transport (7.17.11) - base64 - faraday (>= 1, < 3) + elasticsearch (8.19.3) + elastic-transport (~> 8.3) + elasticsearch-api (= 8.19.3) + ostruct + elasticsearch-api (8.19.3) multi_json - email_spec (2.3.0) - htmlentities (~> 4.3.3) - launchy (>= 2.1, < 4.0) - mail (~> 2.7) - email_validator (2.2.4) - activemodel - erb (5.0.2) + elasticsearch-dsl (0.1.10) + erb (6.0.4) erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo - excon (1.2.8) + excon (1.5.0) logger fabrication (3.0.0) - faker (3.5.2) + faker (3.8.0) i18n (>= 1.8.11, < 2) - faraday (2.13.4) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.3.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.4) + net-http (~> 0.5) fast_blank (1.0.1) - fastimage (2.4.0) - ffi (1.17.2) - ffi-compiler (1.3.2) + fastimage (2.4.1) + ffi (1.17.4) + ffi-compiler (1.4.2) ffi (>= 1.15.5) rake - flatware (2.3.4) + flatware (2.4.0) + benchmark drb + logger thor (< 2.0) - flatware-rspec (2.3.4) - flatware (= 2.3.4) - rspec (>= 3.6) + flatware-rspec (2.4.0) + flatware (= 2.4.0) + rspec (>= 3.8) fog-core (2.6.0) builder excon (~> 1.0) formatador (>= 0.2, < 2.0) mime-types - fog-json (1.2.0) + fog-json (1.3.0) fog-core multi_json (~> 1.10) fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) - formatador (1.1.1) - forwardable (1.3.3) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + formatador (1.2.3) + reline + forwardable (1.4.0) + fugit (1.12.2) + et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.31.1) + google-protobuf (4.35.0) bigdecimal - rake (>= 13) - googleapis-common-protos-types (1.20.0) - google-protobuf (>= 3.18, < 5.a) - haml (6.3.0) + rake (~> 13.3) + googleapis-common-protos-types (1.23.0) + google-protobuf (~> 4.26) + haml (7.2.0) temple (>= 0.8.2) thor tilt - haml-rails (2.1.0) + haml-rails (3.0.0) actionpack (>= 5.1) activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.66.0) + haml_lint (0.73.0) haml (>= 5.0) - parallel (~> 1.10) + parallel (>= 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.2.0) - hashie (5.0.0) + hashdiff (1.2.1) + hashie (5.1.0) + logger hcaptcha (7.1.0) json highline (3.1.2) reline - hiredis (0.6.3) - hiredis-client (0.25.3) - redis-client (= 0.25.3) + hiredis-client (0.30.0) + redis-client (= 0.30.0) hkdf (0.3.0) - htmlentities (4.3.4) + htmlentities (4.4.2) http (5.3.1) addressable (~> 2.8) http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.8) + http-cookie (1.1.6) domain_name (~> 0.5) http-form_data (2.3.0) 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.0.15) + i18n-tasks (1.1.2) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi - highline (>= 2.0.0) + highline (>= 3.0.0) i18n parser (>= 3.2.2.1) + prism rails-i18n rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.8, >= 1.8.1) @@ -335,9 +342,11 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.1) - irb (1.15.2) + io-console (0.8.2) + ipaddr (1.2.9) + irb (1.18.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jd-paperclip-azure (3.0.0) @@ -345,9 +354,9 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.13.2) + json (2.19.9) json-canonicalization (1.0.0) - json-jwt (1.16.7) + json-jwt (1.17.1) activesupport (>= 4.2) aes_key_wrap base64 @@ -365,11 +374,11 @@ GEM json-ld-preloaded (3.3.2) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (6.0.0) + json-schema (6.2.0) addressable (~> 2.8) - bigdecimal (~> 3.1) + bigdecimal (>= 3.1, < 5) jsonapi-renderer (0.2.2) - jwt (2.10.2) + jwt (2.10.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -383,7 +392,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) @@ -403,15 +412,13 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) - linzer (0.7.7) - cgi (~> 0.4.2) + linzer (0.7.9) + cgi (>= 0.4.2, < 0.6.0) forwardable (~> 1.3, >= 1.3.3) logger (~> 1.7, >= 1.7.0) - net-http (~> 0.6.0) - openssl (~> 3.0, >= 3.0.0) + net-http (>= 0.6, < 0.10) rack (>= 2.2, < 4.0) starry (~> 0.2) - stringio (~> 3.1, >= 3.1.2) uri (~> 1.0, >= 1.0.2) llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) @@ -422,10 +429,11 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.24.1) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop @@ -438,16 +446,18 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0916) + mime-types-data (3.2026.0414) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) - msgpack (1.8.0) - multi_json (1.17.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.1) + multi_json (1.21.1) mutex_m (0.3.0) - net-http (0.6.0) - uri - net-imap (0.5.9) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.4.1) date net-protocol net-ldap (0.20.0) @@ -459,34 +469,31 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.18.10) + nio4r (2.7.5) + nokogiri (1.19.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.11) - bigdecimal (>= 3.0) - ostruct (>= 0.2) - omniauth (2.1.3) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-cas (3.0.2) 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) + omniauth-saml (2.2.5) omniauth (~> 2.1) ruby-saml (~> 1.18) omniauth_openid_connect (0.8.0) omniauth (>= 1.9, < 3) openid_connect (~> 2.2) - openid_connect (2.3.1) + openid_connect (2.5.0) activemodel attr_required (>= 1.0.0) - email_validator faraday (~> 2.0) faraday-follow_redirects json-jwt (>= 1.16) @@ -496,128 +503,107 @@ GEM tzinfo validate_url webfinger (~> 2.0) - openssl (3.3.0) + openssl (4.0.2) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.7.0) - opentelemetry-common (0.22.0) + opentelemetry-api (1.10.0) + logger + opentelemetry-common (0.25.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.30.0) + opentelemetry-exporter-otlp (0.34.0) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) - opentelemetry-sdk (~> 1.2) + opentelemetry-sdk (~> 1.10) opentelemetry-semantic_conventions - opentelemetry-helpers-sql (0.1.1) + opentelemetry-helpers-sql (0.4.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-processor (0.5.0) opentelemetry-api (~> 1.0) - opentelemetry-helpers-sql-obfuscation (0.3.0) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.4.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-action_pack (0.13.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.9.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_job (0.8.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_model_serializers (0.22.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_mailer (0.8.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.18.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.13.0) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.13.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.25.0) opentelemetry-instrumentation-active_support (>= 0.7.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_record (0.9.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_storage (0.1.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_support (0.8.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-base (0.23.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_record (0.13.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.5.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.12.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.26.1) + opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-concurrent_ruby (0.22.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-excon (0.24.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-faraday (0.28.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-http (0.25.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-http_client (0.24.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-net_http (0.24.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-pg (0.30.1) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-concurrent_ruby (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.29.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.33.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.30.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.36.0) opentelemetry-helpers-sql - opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rack (0.27.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rails (0.37.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.4.0) - opentelemetry-instrumentation-action_pack (~> 0.13.0) - opentelemetry-instrumentation-action_view (~> 0.9.0) - opentelemetry-instrumentation-active_job (~> 0.8.0) - opentelemetry-instrumentation-active_record (~> 0.9.0) - opentelemetry-instrumentation-active_storage (~> 0.1.0) - opentelemetry-instrumentation-active_support (~> 0.8.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) - opentelemetry-instrumentation-redis (0.26.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-sidekiq (0.26.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-registry (0.4.0) + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rack (0.31.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.42.0) + opentelemetry-instrumentation-action_mailer (~> 0.7) + opentelemetry-instrumentation-action_pack (~> 0.17) + opentelemetry-instrumentation-action_view (~> 0.12) + opentelemetry-instrumentation-active_job (~> 0.11) + opentelemetry-instrumentation-active_record (~> 0.12) + opentelemetry-instrumentation-active_storage (~> 0.4) + opentelemetry-instrumentation-active_support (~> 0.11) + opentelemetry-instrumentation-concurrent_ruby (~> 0.25) + opentelemetry-instrumentation-redis (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-registry (0.6.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.9.0) + opentelemetry-sdk (1.12.0) + logger opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.36.0) + opentelemetry-semantic_conventions (1.39.0) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) ostruct (0.6.3) - ox (2.14.23) + ox (2.14.26) bigdecimal (>= 3.0) - parallel (1.27.0) - parser (3.3.9.0) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.6.2) - pghero (3.7.0) - activerecord (>= 7.1) - playwright-ruby-client (1.55.0) + pg (1.6.3) + pghero (3.8.0) + activerecord (>= 7.2) + playwright-ruby-client (1.60.0) + base64 concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) - pp (0.6.2) + pp (0.6.3) prettyprint - premailer (1.27.0) + premailer (1.29.0) addressable css_parser (>= 1.19.0) htmlentities (>= 4.0.0) @@ -626,83 +612,84 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.4.0) - prometheus_exporter (2.3.0) + prism (1.9.0) + prometheus_exporter (2.3.1) webrick - propshaft (1.2.1) + propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.4.0) date stringio - public_suffix (6.0.2) - puma (7.0.3) + public_suffix (7.0.5) + puma (8.0.2) nio4r (~> 2.0) - pundit (2.5.1) + pundit (2.5.2) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.16) - rack-attack (6.7.0) + rack (3.2.6) + rack-attack (6.8.0) rack (>= 1.0, < 4) 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) - rack-proxy (0.7.7) + rack-proxy (0.8.2) rack - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) 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.2.1) - actioncable (= 8.0.2.1) - actionmailbox (= 8.0.2.1) - actionmailer (= 8.0.2.1) - actionpack (= 8.0.2.1) - actiontext (= 8.0.2.1) - actionview (= 8.0.2.1) - activejob (= 8.0.2.1) - activemodel (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) bundler (>= 1.15.0) - railties (= 8.0.2.1) + railties (= 8.1.3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) 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.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) + rake (13.4.2) rdf (3.3.4) bcp47_spec (~> 0.2) bigdecimal (~> 3.1, >= 3.1.5) @@ -712,110 +699,115 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.14.2) + rdoc (7.2.0) erb psych (>= 4.0.0) + tsort readline (0.0.4) reline redcarpet (3.6.1) - redis (4.8.1) - redis-client (0.25.3) + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.30.0) connection_pool - regexp_parser (2.11.2) - reline (0.6.2) + regexp_parser (2.12.0) + reline (0.6.3) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) rexml (3.4.4) rotp (6.3.0) - rouge (4.6.0) + rouge (5.0.0) + strscan (~> 3.1) 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.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.2) + rspec-rails (8.0.4) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-sidekiq (5.2.0) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-sidekiq (5.3.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) - rspec-support (3.13.4) - rubocop (1.80.2) + rspec-support (3.13.7) + rubocop (1.88.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.46.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.46.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-capybara (2.22.1) + prism (~> 1.7) + rubocop-capybara (2.23.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-i18n (3.2.3) + rubocop (~> 1.81) + rubocop-i18n (3.3.0) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-performance (1.26.0) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rails (2.33.3) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.35.4) 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.7.0) + rubocop-rspec (3.10.2) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-rspec_rails (2.31.0) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + rubocop-rspec_rails (2.32.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec (~> 3.5) - ruby-prof (1.7.2) + ruby-prof (2.0.4) base64 + ostruct ruby-progressbar (1.13.0) 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.1.0) + rubyzip (3.4.0) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) - safety_net_attestation (0.4.0) - jwt (~> 2.0) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) sanitize (7.0.0) crass (~> 1.0.2) nokogiri (>= 1.16.8) @@ -823,28 +815,29 @@ GEM activerecord (>= 4.0.0) railties (>= 4.0.0) securerandom (0.4.1) - shoulda-matchers (6.5.0) - activesupport (>= 5.2.0) - sidekiq (8.0.7) - connection_pool (>= 2.5.0) - json (>= 2.9.0) - logger (>= 1.6.2) - rack (>= 3.1.0) - redis-client (>= 0.23.2) + shoulda-matchers (8.0.1) + activesupport (>= 7.2) + sidekiq (8.1.6) + connection_pool (>= 3.0.0) + json (>= 2.16.0) + logger (>= 1.7.0) + rack (>= 3.2.0) + redis-client (>= 0.29.0) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (6.0.1) + sidekiq-scheduler (6.0.2) rufus-scheduler (~> 3.2) sidekiq (>= 7.3, < 9) - sidekiq-unique-jobs (8.0.11) + sidekiq-unique-jobs (8.1.0) 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.3.1) - actionpack (>= 5.2) - activemodel (>= 5.2) + ostruct + simple_form (5.4.1) + actionpack (>= 7.0) + activemodel (>= 7.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -852,14 +845,17 @@ GEM simplecov-html (0.13.2) simplecov-lcov (0.9.0) simplecov_json_formatter (0.1.4) - stackprof (0.2.27) + ssrf_filter (1.5.0) + stackprof (0.2.28) starry (0.2.0) base64 - stoplight (5.3.8) + stoplight (5.8.2) + concurrent-ruby zeitwerk - stringio (3.1.7) - strong_migrations (2.5.0) - activerecord (>= 7.1) + stringio (3.2.0) + strong_migrations (2.8.0) + activerecord (>= 7.2) + strscan (3.1.8) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) @@ -871,14 +867,15 @@ 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.6.1) + thor (1.5.0) + tilt (2.7.0) + timeout (0.6.1) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) + tsort (0.2.0) tty-color (0.6.0) tty-cursor (0.7.1) tty-prompt (0.23.1) @@ -894,23 +891,23 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.2) + tzinfo-data (1.2026.2) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (3.1.5) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + 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.11.0) railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.9.2) + vite_ruby (3.10.2) dry-cli (>= 0.7, < 2) logger (~> 1.6) mutex_m @@ -918,24 +915,24 @@ GEM zeitwerk (~> 2.2) warden (1.2.9) rack (>= 2.0.9) - webauthn (3.4.1) + webauthn (3.4.3) android_key_attestation (~> 0.3.0) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) openssl (>= 2.2) - safety_net_attestation (~> 0.4.0) + safety_net_attestation (~> 0.5.0) tpm-key_attestation (~> 0.14.0) webfinger (2.1.3) activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.25.1) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) - websocket-driver (0.8.0) + webrick (1.9.2) + websocket-driver (0.8.1) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -943,7 +940,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.3) + zeitwerk (2.8.2) PLATFORMS ruby @@ -952,19 +949,20 @@ DEPENDENCIES active_model_serializers (~> 0.10) addressable (~> 2.8) annotaterb (~> 4.13) - aws-sdk-core (< 3.216.0) + aws-sdk-core aws-sdk-s3 (~> 1.123) + base58 (~> 0.2.3) better_errors (~> 2.9) - binding_of_caller (~> 1.0) + binding_of_caller blurhash (~> 0.1) - bootsnap (~> 1.18.0) - brakeman (~> 7.0) + bootsnap + brakeman (~> 8.0) browser bundler-audit (~> 0.9) capybara (~> 3.39) capybara-playwright-driver charlock_holmes (~> 0.7.7) - chewy (~> 7.3) + chewy climate_control cocoon (~> 1.2) color_diff (~> 0.1) @@ -973,13 +971,12 @@ 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) + discard (~> 2.0) doorkeeper (~> 5.6) dotenv - email_spec fabrication faker (~> 3.2) faraday-httpclient @@ -988,21 +985,22 @@ DEPENDENCIES flatware-rspec fog-core (<= 2.6.0) fog-openstack (~> 1.0) - haml-rails (~> 2.0) + haml-rails (~> 3.0) haml_lint hcaptcha (~> 7.1) - hiredis (~> 0.6) hiredis-client 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 inline_svg + ipaddr (~> 1.2) irb (~> 1.8) jd-paperclip-azure (~> 3.0) + json json-ld json-ld-preloaded (~> 3.2) json-schema (~> 6.0) @@ -1018,50 +1016,49 @@ DEPENDENCIES memory_profiler mime-types (~> 3.7.0) mutex_m - net-http (~> 0.6.0) + net-http (~> 0.9.0) net-ldap (~> 0.18) nokogiri (~> 1.15) - 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) - opentelemetry-exporter-otlp (~> 0.30.0) - opentelemetry-instrumentation-active_job (~> 0.8.0) - opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) - opentelemetry-instrumentation-excon (~> 0.24.0) - opentelemetry-instrumentation-faraday (~> 0.28.0) - opentelemetry-instrumentation-http (~> 0.25.0) - opentelemetry-instrumentation-http_client (~> 0.24.0) - opentelemetry-instrumentation-net_http (~> 0.24.0) - opentelemetry-instrumentation-pg (~> 0.30.0) - opentelemetry-instrumentation-rack (~> 0.27.0) - opentelemetry-instrumentation-rails (~> 0.37.0) - opentelemetry-instrumentation-redis (~> 0.26.0) - opentelemetry-instrumentation-sidekiq (~> 0.26.0) + opentelemetry-api (~> 1.10.0) + opentelemetry-exporter-otlp (~> 0.34.0) + opentelemetry-instrumentation-active_job (~> 0.13.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.25.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.25.0) + opentelemetry-instrumentation-excon (~> 0.29.0) + opentelemetry-instrumentation-faraday (~> 0.33.0) + opentelemetry-instrumentation-http (~> 0.30.0) + opentelemetry-instrumentation-http_client (~> 0.29.0) + opentelemetry-instrumentation-net_http (~> 0.29.0) + opentelemetry-instrumentation-pg (~> 0.36.0) + opentelemetry-instrumentation-rack (~> 0.31.0) + opentelemetry-instrumentation-rails (~> 0.42.0) + opentelemetry-instrumentation-redis (~> 0.29.0) + opentelemetry-instrumentation-sidekiq (~> 0.29.0) opentelemetry-sdk (~> 1.4) ox (~> 2.14) parslet pg (~> 1.5) pghero - playwright-ruby-client (= 1.55.0) + playwright-ruby-client (= 1.60.0) premailer-rails prometheus_exporter (~> 2.2) propshaft - public_suffix (~> 6.0) - puma (~> 7.0) + public_suffix (~> 7.0) + puma pundit (~> 2.3) rack-attack (~> 6.6) rack-cors rack-test (~> 2.1) - rails (~> 8.0) + rails (~> 8.1.0) rails-i18n (~> 8.0) rdf-normalize (~> 0.5) redcarpet (~> 3.6) - redis (~> 4.5) + redis (~> 5) rqrcode (~> 3.0) rspec-github (~> 3.0) rspec-rails (~> 8.0) @@ -1096,14 +1093,15 @@ DEPENDENCIES tty-prompt (~> 0.23) twitter-text (~> 3.1.0) tzinfo-data (~> 1.2023) - vite_rails (~> 3.0.19) + vite_rails webauthn (~> 3.0) webmock (~> 3.18) webpush! + websocket-driver (~> 0.8) xorcist (~> 1.1) RUBY VERSION - ruby 3.4.1p0 + ruby 4.0.5 BUNDLED WITH - 2.7.1 + 4.0.13 diff --git a/README.md b/README.md index ba800f59d5aed8..7d468abbd244c1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Mastodon Glitch Edition is a fork of [Mastodon](https://github.com/mastodon/mast

- Mastodon hero image + Mastodon hero image

@@ -72,10 +72,11 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub] ### Requirements -- **Ruby** 3.2+ -- **PostgreSQL** 13+ +- **Ruby** 3.3+ +- **PostgreSQL** 14+ - **Redis** 7.0+ -- **Node.js** 20+ +- **Node.js** 22+ +- **FFmpeg** 5.1+ This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation. diff --git a/SECURITY.md b/SECURITY.md index 19f431fac5948e..8e359433074c62 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through | Version | Supported | | ------- | ---------------- | -| 4.4.x | Yes | -| 4.3.x | Yes | -| 4.2.x | Until 2026-01-08 | -| < 4.2 | No | +| 4.6.0 | Yes | +| 4.5.x | Yes | +| 4.4.x | Until 2026-12-17 | +| < 4.4 | No | diff --git a/Vagrantfile b/Vagrantfile index 0a34367024070a..df91fceea45b22 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -12,7 +12,7 @@ sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main' # Add repo for NodeJS sudo mkdir -p /etc/apt/keyrings curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg -NODE_MAJOR=20 +NODE_MAJOR=24 echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list sudo apt-get update @@ -29,7 +29,6 @@ sudo apt-get install \ libpq-dev \ libxml2-dev \ libxslt1-dev \ - imagemagick \ nodejs \ redis-server \ redis-tools \ @@ -116,7 +115,7 @@ gem install bundler foreman bundle install # Install node modules -sudo corepack enable +sudo npm i -g corepack corepack prepare yarn install diff --git a/app/chewy/public_statuses_index.rb b/app/chewy/public_statuses_index.rb index 09a4dfc09320a3..c847b0283d4c07 100644 --- a/app/chewy/public_statuses_index.rb +++ b/app/chewy/public_statuses_index.rb @@ -53,9 +53,9 @@ class PublicStatusesIndex < Chewy::Index } index_scope ::Status.unscoped - .kept - .indexable - .includes(:media_attachments, :preloadable_poll, :tags, preview_cards_status: :preview_card) + .kept + .indexable + .includes(:media_attachments, :preloadable_poll, :tags, preview_cards_status: :preview_card) root date_detection: false do field(:id, type: 'long') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 685b02ae6d99b1..af0cb05bc34fb8 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -18,6 +18,8 @@ def show respond_to do |format| format.html do expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in? + + redirect_to short_account_path(@account) if account_id_param.present? && username_param.blank? end format.rss do @@ -71,6 +73,10 @@ def username_param params[:username] end + def account_id_param + params[:id] + end + def skip_temporary_suspension_response? request.format == :json end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index adc5e935135560..6647d0999736fb 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/feature_authorizations_controller.rb b/app/controllers/activitypub/feature_authorizations_controller.rb new file mode 100644 index 00000000000000..ef9f458bf78b9d --- /dev/null +++ b/app/controllers/activitypub/feature_authorizations_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ActivityPub::FeatureAuthorizationsController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_collection_item + + def show + expires_in 30.seconds, public: true if public_fetch_mode? + render json: @collection_item, serializer: ActivityPub::FeatureAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_collection_item + @collection_item = @account.collection_items.accepted.find(params[:id]) + + authorize @collection_item.collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/activitypub/featured_collections_controller.rb b/app/controllers/activitypub/featured_collections_controller.rb new file mode 100644 index 00000000000000..26c725d37538ce --- /dev/null +++ b/app/controllers/activitypub/featured_collections_controller.rb @@ -0,0 +1,74 @@ +# 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 :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 + .includes(:accepted_collection_items) + .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.id, page: @collections.next_page) if @collections.respond_to?(:next_page) + end + + def prev_page_url + ap_account_featured_collections_url(@account.id, 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.id, page: params.fetch(:page, 1)), + type: :unordered, + size: @account.collections.count, + items: @collections, + part_of: ap_account_featured_collections_url(@account.id), + next: next_page_url, + prev: prev_page_url + ) + else + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account.id), + type: :unordered, + size: @account.collections.count, + first: ap_account_featured_collections_url(@account.id, page: 1) + ) + end + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 49cfc8ad1cbd8c..b5926d94fdaf1f 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,14 +17,18 @@ 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 def unknown_affected_account? - json = Oj.load(body, mode: :strict) + json = JSON.parse(body) json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.exists?(uri: json['actor']) - rescue Oj::ParseError + rescue JSON::ParserError false 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 4aa6a4a771f156..4dcddb88e4bfa1 100644 --- a/app/controllers/activitypub/likes_controller.rb +++ b/app/controllers/activitypub/likes_controller.rb @@ -22,13 +22,13 @@ 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 def likes_collection_presenter ActivityPub::CollectionPresenter.new( - id: account_status_likes_url(@account, @status), + id: ActivityPub::TagManager.instance.likes_uri_for(@status), type: :unordered, size: @status.favourites_count ) diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index a9476b806f54e1..928977768b9ed6 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -73,6 +73,8 @@ def page_params end def set_account - @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative + return super if params[:account_username].present? || params[:account_id].present? + + @account = Account.representative end end diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb index f2f5313e1ad3c6..ff4a76df34ced2 100644 --- a/app/controllers/activitypub/quote_authorizations_controller.rb +++ b/app/controllers/activitypub/quote_authorizations_controller.rb @@ -9,7 +9,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController before_action :set_quote_authorization def show - expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode? + expires_in 30.seconds, public: true if @quote.quoted_status.distributable? && public_fetch_mode? render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end @@ -23,8 +23,8 @@ def set_quote_authorization @quote = Quote.accepted.where(quoted_account: @account).find(params[:id]) return not_found unless @quote.status.present? && @quote.quoted_status.present? - authorize @quote.status, :show? - rescue Mastodon::NotPermittedError + authorize @quote.quoted_status, :show? + 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 0a19275d38e942..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 @@ -37,7 +37,7 @@ def set_replies def replies_collection_presenter page = ActivityPub::CollectionPresenter.new( - id: account_status_replies_url(@account, @status, page_params), + id: ActivityPub::TagManager.instance.replies_uri_for(@status, page_params), type: :unordered, part_of: account_status_replies_url(@account, @status), next: next_page, @@ -47,7 +47,7 @@ def replies_collection_presenter return page if page_requested? ActivityPub::CollectionPresenter.new( - id: account_status_replies_url(@account, @status), + id: ActivityPub::TagManager.instance.replies_uri_for(@status), type: :unordered, first: page ) @@ -66,8 +66,7 @@ def next_page # Only consider remote accounts return nil if @replies.size < DESCENDANTS_LIMIT - account_status_replies_url( - @account, + ActivityPub::TagManager.instance.replies_uri_for( @status, page: true, min_id: @replies&.last&.id, @@ -77,8 +76,7 @@ def next_page # For now, we're serving only self-replies, but next page might be other accounts next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT - account_status_replies_url( - @account, + ActivityPub::TagManager.instance.replies_uri_for( @status, page: true, min_id: next_only_other_accounts ? nil : @replies&.last&.id, diff --git a/app/controllers/activitypub/shares_controller.rb b/app/controllers/activitypub/shares_controller.rb index 65b4a5b3831326..3733dfbd6f3fa6 100644 --- a/app/controllers/activitypub/shares_controller.rb +++ b/app/controllers/activitypub/shares_controller.rb @@ -22,13 +22,13 @@ 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 def shares_collection_presenter ActivityPub::CollectionPresenter.new( - id: account_status_shares_url(@account, @status), + id: ActivityPub::TagManager.instance.shares_uri_for(@status), type: :unordered, size: @status.reblogs_count ) diff --git a/app/controllers/admin/collections_controller.rb b/app/controllers/admin/collections_controller.rb new file mode 100644 index 00000000000000..405c779e3d43c8 --- /dev/null +++ b/app/controllers/admin/collections_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Admin + class CollectionsController < BaseController + before_action :set_account + before_action :set_collection, only: :show + before_action :set_collections, except: :show + + PER_PAGE = 20 + + def index + authorize [:admin, :collection], :index? + @collection_batch_action = Admin::CollectionBatchAction.new + end + + def show + authorize @collection, :show? + end + + def batch + authorize [:admin, :collection], :index? + + @collection_batch_action = Admin::CollectionBatchAction.new(admin_collection_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button)) + + @collection_batch_action.save! + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.collections.no_collection_selected') + ensure + redirect_to after_create_redirect_path + end + + private + + def after_create_redirect_path + report_id = @collections_batch_action&.report_id || params[:report_id] + + if report_id.present? + admin_report_path(report_id) + else + admin_account_collections_path(params[:account_id], params[:page]) + end + end + + def admin_collection_batch_action_params + params + .expect(admin_collection_batch_action: [collection_ids: []]) + end + + def set_account + @account = Account.find(params[:account_id]) + end + + def set_collection + @collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id]) + end + + def set_collections + @collections = @account.collections.includes(accepted_collection_items: :account).page(params[:page]).per(PER_PAGE) + end + + def action_from_button + if params[:report] + 'report' + elsif params[:remove_from_report] + 'remove_from_report' + end + end + end +end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index fbef61810d42c6..9ff587632fe49c 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -5,6 +5,12 @@ class CustomEmojisController < BaseController def index authorize :custom_emoji, :index? + # 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/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 5b0867dcfbac2f..fe314daeca69f6 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -9,10 +9,16 @@ def index @pending_appeals_count = Appeal.pending.async_count @pending_reports_count = Report.unresolved.async_count - @pending_tags_count = Tag.pending_review.async_count + @pending_tags_count = pending_tags.async_count @pending_users_count = User.pending.async_count @system_checks = Admin::SystemCheck.perform(current_user) @time_period = (29.days.ago.to_date...Time.now.utc.to_date) end + + private + + def pending_tags + ::Trends::TagFilter.new(status: :pending_review).results + end end end diff --git a/app/controllers/admin/disputes/strikes_controller.rb b/app/controllers/admin/disputes/strikes_controller.rb new file mode 100644 index 00000000000000..e57c713564fe92 --- /dev/null +++ b/app/controllers/admin/disputes/strikes_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Admin::Disputes::StrikesController < Disputes::StrikesController +end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 5e1074b224ae3e..fdc8e53f531c67 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -54,7 +54,7 @@ def create end # Allow transparently upgrading a domain block - if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(@domain_block.domain.strip) + if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(@domain_block.domain) @domain_block = existing_domain_block @domain_block.assign_attributes(resource_params) end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index 12f221164fa749..d248a04d61bc91 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -5,7 +5,7 @@ class EmailDomainBlocksController < BaseController def index authorize :email_domain_block, :index? - @email_domain_blocks = EmailDomainBlock.parents.includes(:children).order(id: :desc).page(params[:page]) + @email_domain_blocks = filter_by_domain.page(params[:page]) @form = Form::EmailDomainBlockBatch.new end @@ -57,6 +57,12 @@ def create private + def filter_by_domain + scope = EmailDomainBlock.parents.includes(:children).order(id: :desc) + scope.merge!(EmailDomainBlock.matches_domain(params[:domain])) if params[:domain].present? + scope + end + def set_resolved_records @resolved_records = DomainResource.new(@email_domain_block.domain).mx end diff --git a/app/controllers/admin/email_subscriptions/accounts_controller.rb b/app/controllers/admin/email_subscriptions/accounts_controller.rb new file mode 100644 index 00000000000000..a5ae9b39201d36 --- /dev/null +++ b/app/controllers/admin/email_subscriptions/accounts_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Admin::EmailSubscriptions::AccountsController < Admin::BaseController + before_action :require_enabled! + before_action :set_account + + def show + authorize :email_subscription, :show? + @email_subscriptions_count = EmailSubscription.where(account: @account).count + @email_subscriptions = EmailSubscription.where(account: @account).page(params[:page]) + end + + def enable + authorize :email_subscription, :enable? + @account.user.settings['email_subscriptions'] = true + @account.user.save! + redirect_to admin_email_subscriptions_account_path(@account.id) + end + + def disable + authorize :email_subscription, :disable? + @account.user.settings['email_subscriptions'] = false + @account.user.save! + redirect_to admin_email_subscriptions_account_path(@account.id) + end + + private + + def require_enabled! + raise ActionController::RoutingError, 'Feature disabled' unless Rails.application.config.x.email_subscriptions + end + + def set_account + @account = Account.find(params[:id]) + end +end diff --git a/app/controllers/admin/email_subscriptions/additional_footer_texts_controller.rb b/app/controllers/admin/email_subscriptions/additional_footer_texts_controller.rb new file mode 100644 index 00000000000000..fcc774a25696d2 --- /dev/null +++ b/app/controllers/admin/email_subscriptions/additional_footer_texts_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Admin::EmailSubscriptions::AdditionalFooterTextsController < Admin::SettingsController + private + + def after_update_redirect_path + admin_email_subscriptions_path + end +end diff --git a/app/controllers/admin/email_subscriptions/setups_controller.rb b/app/controllers/admin/email_subscriptions/setups_controller.rb new file mode 100644 index 00000000000000..81f62671719d50 --- /dev/null +++ b/app/controllers/admin/email_subscriptions/setups_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Admin::EmailSubscriptions::SetupsController < Admin::BaseController + before_action :require_enabled! + + def show + authorize :email_subscription, :enable? + + @form = Form::EmailSubscriptionsConfirmation.new + end + + def create + authorize :email_subscription, :enable? + + @form = Form::EmailSubscriptionsConfirmation.new(resource_params) + + if @form.valid? + Setting.email_subscriptions = true + redirect_to admin_email_subscriptions_path + else + render :show + end + end + + private + + def require_enabled! + raise ActionController::RoutingError, 'Feature disabled' unless Rails.application.config.x.email_subscriptions + end + + def resource_params + params.expect(form_email_subscriptions_confirmation: [:agreement_email_volume, :agreement_privacy_and_terms]) + end +end diff --git a/app/controllers/admin/email_subscriptions_controller.rb b/app/controllers/admin/email_subscriptions_controller.rb new file mode 100644 index 00000000000000..dae7d452b87e32 --- /dev/null +++ b/app/controllers/admin/email_subscriptions_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Admin::EmailSubscriptionsController < Admin::BaseController + before_action :set_email_subscription, only: :destroy + + def index + authorize :email_subscription, :index? + + @enabled = Setting.email_subscriptions + @roles = UserRole.where('permissions & ? != 0', UserRole::FLAGS[:manage_email_subscriptions] | UserRole::FLAGS[:administrator]) + @accounts = Account.local.where.associated(:email_subscriptions).includes(:user) + end + + def destroy + authorize :email_subscription, :destroy? + @email_subscription.destroy! + redirect_to admin_email_subscriptions_account_path(@email_subscription.account_id) + end + + def disable + authorize :email_subscription, :disable? + Setting.email_subscriptions = false + redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.disabled_msg') + end + + def purge + authorize :email_subscription, :purge? + Admin::EmailSubscriptionsPurgeWorker.perform_async + redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.purged_msg') + end + + private + + def set_email_subscription + @email_subscription = EmailSubscription.find(params[:id]) + end +end diff --git a/app/controllers/admin/fasp/debug/callbacks_controller.rb b/app/controllers/admin/fasp/debug/callbacks_controller.rb index 28aba5e48925b1..acba4c51d83f6f 100644 --- a/app/controllers/admin/fasp/debug/callbacks_controller.rb +++ b/app/controllers/admin/fasp/debug/callbacks_controller.rb @@ -5,8 +5,8 @@ def index authorize [:admin, :fasp, :provider], :update? @callbacks = Fasp::DebugCallback - .includes(:fasp_provider) - .order(created_at: :desc) + .includes(:fasp_provider) + .order(created_at: :desc) end def destroy diff --git a/app/controllers/admin/instances/moderation_notes_controller.rb b/app/controllers/admin/instances/moderation_notes_controller.rb index 635c09734933ee..dd6c32bda5799e 100644 --- a/app/controllers/admin/instances/moderation_notes_controller.rb +++ b/app/controllers/admin/instances/moderation_notes_controller.rb @@ -34,8 +34,11 @@ def resource_params end def set_instance - domain = params[:instance_id]&.strip - @instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain)) + @instance = Instance.find_or_initialize_by(domain: normalized_domain) + end + + def normalized_domain + TagManager.instance.normalize_domain(params[:instance_id]) end def set_instance_note diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 6ab4acab99cf58..033d250a2e07c3 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -55,8 +55,11 @@ def stop_delivery private def set_instance - domain = params[:id]&.strip - @instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain)) + @instance = Instance.find_or_initialize_by(domain: normalized_domain) + end + + def normalized_domain + TagManager.instance.normalize_domain(params[:id]) end def set_instances diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb index afabda1b886e4b..5f917b18808743 100644 --- a/app/controllers/admin/ip_blocks_controller.rb +++ b/app/controllers/admin/ip_blocks_controller.rb @@ -5,7 +5,7 @@ class IpBlocksController < BaseController def index authorize :ip_block, :index? - @ip_blocks = IpBlock.order(ip: :asc).page(params[:page]) + @ip_blocks = filter_by_ip(IpBlock.order(ip: :asc).page(params[:page])) @form = Form::IpBlockBatch.new end @@ -43,6 +43,11 @@ def batch private + def filter_by_ip(scope) + scope.merge!(IpBlock.overlapping_with(params[:ip])) if params[:ip].present? + scope + end + def resource_params params .expect(ip_block: [:ip, :severity, :comment, :expires_in]) diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index 10dbe846e4ce10..03234b0bde4f97 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -25,6 +25,8 @@ def create @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new @statuses = @report.statuses.with_includes + @collections = @report.collections + @collection_form = Admin::CollectionBatchAction.new render 'admin/reports/show' end diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb index fb7b6878baedfb..abfec42e75be41 100644 --- a/app/controllers/admin/reports/actions_controller.rb +++ b/app/controllers/admin/reports/actions_controller.rb @@ -13,7 +13,7 @@ def create case action_from_button when 'delete', 'mark_as_sensitive' - Admin::StatusBatchAction.new(status_batch_action_params).save! + Admin::ModerationAction.new(moderation_action_params).save! when 'silence', 'suspend' Admin::AccountAction.new(account_action_params).save! else @@ -25,9 +25,8 @@ def create private - def status_batch_action_params + def moderation_action_params shared_params - .merge(status_ids: @report.status_ids) end def account_action_params diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index aa877f1448c98e..f12e3da4e1121e 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -16,6 +16,8 @@ def show @report_notes = @report.notes.chronological.includes(:account) @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new + @collection_form = Admin::CollectionBatchAction.new + @collections = @report.collections @statuses = @report.statuses.with_includes end @@ -50,7 +52,7 @@ def resolve private def filtered_reports - ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account) + ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account, :collections) end def filter_params @@ -58,7 +60,7 @@ def filter_params end def set_report - @report = Report.find(params[:id]) + @report = Report.includes(collections: :accepted_collection_items).find(params[:id]) end end end diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index 2f9af8a6fc77f9..abaa54b4a90e3a 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -62,7 +62,7 @@ def set_role def resource_params params - .expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []]) + .expect(user_role: [:name, :color, :highlighted, :position, :require_2fa, :collection_limit, permissions_as_keys: []]) end end end diff --git a/app/controllers/admin/site_uploads_controller.rb b/app/controllers/admin/site_uploads_controller.rb index 96e61cf6bbc194..e30c783a493dfd 100644 --- a/app/controllers/admin/site_uploads_controller.rb +++ b/app/controllers/admin/site_uploads_controller.rb @@ -9,7 +9,7 @@ def destroy @site_upload.destroy! - redirect_back fallback_location: admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') + redirect_back_or_to admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') end private diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index aeadb35e7a6e77..21c4e5529e2f8d 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -62,7 +62,11 @@ def set_status end def set_statuses - @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE) + @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(*preload_columns, reblog: [:account, *preload_columns]).page(params[:page]).per(PER_PAGE) + end + + def preload_columns + [:application, :preloadable_poll, :media_attachments, active_mentions: :account] end def filter_params @@ -78,8 +82,6 @@ def action_from_button 'report' elsif params[:remove_from_report] 'remove_from_report' - elsif params[:delete] - 'delete' end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 68c4f3962a82cc..601b8e79853c56 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -100,4 +100,8 @@ def disallow_unauthenticated_api_access? def respond_with_error(code) render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code end + + def alpha_path? + request.path.starts_with?('/api/v1_alpha') + end end diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb index f786ea1767fcce..a05d0049bab53e 100644 --- a/app/controllers/api/fasp/base_controller.rb +++ b/app/controllers/api/fasp/base_controller.rb @@ -47,7 +47,7 @@ def validate_signature! provider = nil Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid| - provider = Fasp::Provider.find(keyid) + provider = Fasp::Provider.confirmed.find(keyid) Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid) end diff --git a/app/controllers/api/v1/accounts/email_subscriptions_controller.rb b/app/controllers/api/v1/accounts/email_subscriptions_controller.rb new file mode 100644 index 00000000000000..bf7a1447e1ffca --- /dev/null +++ b/app/controllers/api/v1/accounts/email_subscriptions_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::EmailSubscriptionsController < Api::BaseController + before_action :set_account + before_action :require_feature_enabled! + before_action :require_account_permissions! + + def create + @account.email_subscriptions.create!(email: params[:email], locale: I18n.locale) + render_empty + rescue ActiveRecord::RecordInvalid => e + render json: ValidationErrorFormatter.new(e).as_json, status: 422 + end + + private + + def set_account + @account = Account.local.find(params[:account_id]) + end + + def require_feature_enabled! + head 404 unless Rails.application.config.x.email_subscriptions && Setting.email_subscriptions + end + + def require_account_permissions! + head 404 if @account.unavailable? || !@account.user_can?(:manage_email_subscriptions) || !@account.user_email_subscriptions_enabled? + end +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/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 936cd56eb8ef12..8738da3c941a9c 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -38,7 +38,7 @@ def create headers.merge!(response.headers) - self.response_body = Oj.dump(response.body) + self.response_body = response.body.to_json self.status = response.status rescue ActiveRecord::RecordInvalid => e render json: ValidationErrorFormatter.new(e, 'account.username': :username, 'invite_request.text': :reason).as_json, status: 422 diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index 9b5beeab67ee79..765996eb3cf3f4 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -16,6 +16,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController FILTER_PARAMS = %i( resolved + unresolved account_id target_account_id ).freeze 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/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index d7516c927bc714..e79b292e5f0efc 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -18,14 +18,14 @@ def load_accounts def paginated_blocks @paginated_blocks ||= Block.eager_load(target_account: [:account_stat, :user]) - .joins(:target_account) - .merge(Account.without_suspended) - .where(account: current_account) - .paginate_by_max_id( - limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id] - ) + .joins(:target_account) + .merge(Account.without_suspended) + .where(account: current_account) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) end def next_path diff --git a/app/controllers/api/v1/collection_items_controller.rb b/app/controllers/api/v1/collection_items_controller.rb new file mode 100644 index 00000000000000..6b7db97b0692d9 --- /dev/null +++ b/app/controllers/api/v1/collection_items_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Api::V1::CollectionItemsController < Api::BaseController + include Authorization + include DeprecationConcern + + deprecate_api '2026-06-10', if: :alpha_path? + + 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, :revoke] + + 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? + + DeleteCollectionItemService.new.call(@collection_item) + + head 200 + end + + def revoke + authorize @collection_item, :revoke? + + RevokeCollectionItemService.new.call(@collection_item) + + 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 +end diff --git a/app/controllers/api/v1/collections_controller.rb b/app/controllers/api/v1/collections_controller.rb new file mode 100644 index 00000000000000..08453a7ed6a580 --- /dev/null +++ b/app/controllers/api/v1/collections_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +class Api::V1::CollectionsController < Api::BaseController + include Authorization + include DeprecationConcern + + DEFAULT_COLLECTIONS_LIMIT = 40 + MAX_COLLECTIONS_LIMIT = 100 + + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422 + end + + deprecate_api '2026-06-10', if: :alpha_path? + + 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, MAX_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 next_path + return unless records_continue? + + api_v1_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_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/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index 60db082a8e71a1..5f09d0c8864329 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -37,20 +37,20 @@ def set_conversation def paginated_conversations AccountConversation.where(account: current_account) - .includes( - account: [:account_stat, user: :role], - last_status: [ - :media_attachments, - :status_stat, - :tags, - { - preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, - active_mentions: :account, - account: [:account_stat, user: :role], - }, - ] - ) - .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + .includes( + account: [:account_stat, user: :role], + last_status: [ + :media_attachments, + :status_stat, + :tags, + { + preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, + active_mentions: :account, + account: [:account_stat, user: :role], + }, + ] + ) + .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end def next_path diff --git a/app/controllers/api/v1/donation_campaigns_controller.rb b/app/controllers/api/v1/donation_campaigns_controller.rb new file mode 100644 index 00000000000000..43df1fdc3eb88a --- /dev/null +++ b/app/controllers/api/v1/donation_campaigns_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class Api::V1::DonationCampaignsController < Api::BaseController + before_action :require_user! + + STOPLIGHT_COOL_OFF_TIME = 60 + STOPLIGHT_FAILURE_THRESHOLD = 10 + + def index + return head 204 if api_url.blank? + + json = from_cache + return render json: json if json.present? + + campaign = fetch_campaign + return head 204 if campaign.nil? + + save_to_cache!(campaign) + + render json: campaign + end + + private + + def api_url + Rails.configuration.x.donation_campaigns.api_url + end + + def seed + @seed ||= Random.new(current_account.id).rand(100) + end + + def from_cache + key = Rails.cache.read(request_key, raw: true) + return if key.blank? + + campaign = Rails.cache.read("donation_campaign:#{key}", raw: true) + JSON.parse(campaign) if campaign.present? + end + + def save_to_cache!(campaign) + return if campaign.blank? + + Rails.cache.write_multi( + { + request_key => campaign_key(campaign), + "donation_campaign:#{campaign_key(campaign)}" => campaign.to_json, + }, + expires_in: 1.hour, + raw: true + ) + end + + def fetch_campaign + stoplight_wrapper.run do + url = Addressable::URI.parse(api_url) + url.query_values = { platform: 'web', seed: seed, locale: locale, environment: Rails.configuration.x.donation_campaigns.environment }.compact + + Request.new(:get, url.to_s).perform do |res| + return JSON.parse(res.body_with_limit) if res.code == 200 + end + end + rescue *Mastodon::HTTP_CONNECTION_ERRORS, JSON::ParserError + nil + end + + def stoplight_wrapper + Stoplight( + 'donation_campaigns', + cool_off_time: STOPLIGHT_COOL_OFF_TIME, + threshold: STOPLIGHT_FAILURE_THRESHOLD + ) + end + + def request_key + "donation_campaign_request:#{seed}:#{locale}" + end + + def campaign_key(campaign) + "#{campaign['id']}:#{campaign['locale']}" + end + + def locale + I18n.locale.to_s + end +end diff --git a/app/controllers/api/v1/in_collections_controller.rb b/app/controllers/api/v1/in_collections_controller.rb new file mode 100644 index 00000000000000..c34845e463ecfe --- /dev/null +++ b/app/controllers/api/v1/in_collections_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class Api::V1::InCollectionsController < Api::BaseController + include Authorization + + DEFAULT_COLLECTIONS_LIMIT = 40 + + before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index] + + before_action :require_user! + before_action :set_account, only: [:index] + before_action :set_collections, only: [:index] + + after_action :insert_pagination_headers, only: [:index] + + after_action :verify_authorized + + def index + cache_if_unauthenticated! + authorize @account, :index_featured_in_collections? + + render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def set_collections + @collections = @account.featured_in_collections + .with_tag + .offset(offset_param) + .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + end + + def next_path + return unless records_continue? + + api_v1_account_in_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def prev_path + return if offset_param.zero? + + api_v1_account_in_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.featured_in_collections.size + end + + def offset_param + params[:offset].to_i + end +end diff --git a/app/controllers/api/v1/instances/terms_of_service_controller.rb b/app/controllers/api/v1/instances/terms_of_service_controller.rb new file mode 100644 index 00000000000000..9968b41317eb2f --- /dev/null +++ b/app/controllers/api/v1/instances/terms_of_service_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::V1::Instances::TermsOfServiceController < Api::V1::Instances::BaseController + before_action :cache_even_if_authenticated! + + def index + @terms_of_service = TermsOfService.current || raise(ActiveRecord::RecordNotFound) + render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer + end + + def show + @terms_of_service = TermsOfService.published.find_by!(effective_date: params[:date]) + render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer + end +end diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb deleted file mode 100644 index a32438e31d8c39..00000000000000 --- a/app/controllers/api/v1/instances/terms_of_services_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController - before_action :set_terms_of_service - - def show - cache_even_if_authenticated! - render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer - end - - private - - def set_terms_of_service - @terms_of_service = begin - if params[:date].present? - TermsOfService.published.find_by!(effective_date: params[:date]) - else - TermsOfService.current - end - end - not_found if @terms_of_service.nil? - end -end diff --git a/app/controllers/api/v1/markers_controller.rb b/app/controllers/api/v1/markers_controller.rb index 8eaf7767df87e0..cfb708ff3082d6 100644 --- a/app/controllers/api/v1/markers_controller.rb +++ b/app/controllers/api/v1/markers_controller.rb @@ -32,13 +32,7 @@ def create private def serialize_map(map) - serialized = {} - - map.each_pair do |key, value| - serialized[key] = ActiveModelSerializers::SerializableResource.new(value, serializer: REST::MarkerSerializer).as_json - end - - Oj.dump(serialized) + map.transform_values { |value| ActiveModelSerializers::SerializableResource.new(value, serializer: REST::MarkerSerializer) } end def resource_params diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index d2b50e333662a3..2c213ca20217df 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -18,14 +18,14 @@ def load_accounts def paginated_mutes @paginated_mutes ||= Mute.eager_load(target_account: [:account_stat, :user]) - .joins(:target_account) - .merge(Account.without_suspended) - .where(account: current_account) - .paginate_by_max_id( - limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id] - ) + .joins(:target_account) + .merge(Account.without_suspended) + .where(account: current_account) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) end def next_path diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 9d70c283bec001..0cad5fe0b8cc8f 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -31,7 +31,8 @@ def resource_params :filter_not_following, :filter_not_followers, :filter_new_accounts, - :filter_private_mentions + :filter_private_mentions, + :filter_bots ) end end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index bdc163d4b67036..3d5399d978e5d1 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -16,7 +16,7 @@ def index @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) end - render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships + render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships, supported_notification_types: params[:supported_types] end def unread_count @@ -29,7 +29,7 @@ def unread_count def show @notification = current_account.notifications.without_suspended.find(params[:id]) - render json: @notification, serializer: REST::NotificationSerializer + render json: @notification, serializer: REST::NotificationSerializer, supported_notification_types: params[:supported_types] end def clear @@ -98,6 +98,8 @@ def browserable_params end def pagination_params(core_params) - params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params) + params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered, :supported_types) + .permit(:limit, :account_id, :include_filtered, types: [], exclude_types: [], supported_types: []) + .merge(core_params) end end diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb index d9c82327022194..27b7503e9f0784 100644 --- a/app/controllers/api/v1/peers/search_controller.rb +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -47,10 +47,6 @@ def set_domains end def normalized_domain - TagManager.instance.normalize_domain(query_value) - end - - def query_value - params[:q].strip + TagManager.instance.normalize_domain(params[:q]) 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/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb new file mode 100644 index 00000000000000..02907f4fb44461 --- /dev/null +++ b/app/controllers/api/v1/profiles_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Api::V1::ProfilesController < Api::BaseController + before_action -> { doorkeeper_authorize! :profile, :read, :'read:accounts' }, except: [:update] + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:update] + before_action :require_user! + + def show + @account = current_account + render json: @account, serializer: REST::ProfileSerializer + end + + def update + @account = current_account + UpdateAccountService.new.call(@account, account_params, raise_error: true) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) + + render json: @account, serializer: REST::ProfileSerializer + rescue ActiveRecord::RecordInvalid => e + render json: ValidationErrorFormatter.new(e).as_json, status: 422 + end + + def account_params + params.permit( + :display_name, + :note, + :avatar, + :avatar_description, + :header, + :header_description, + :locked, + :bot, + :discoverable, + :hide_collections, + :indexable, + :show_media, + :show_media_replies, + :show_featured, + attribution_domains: [], + fields_attributes: [:name, :value] + ) + end +end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 72f358bb5bcd95..a8653631c27379 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -23,6 +23,6 @@ def reported_account end def report_params - params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], rule_ids: []) + params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], collection_ids: [], rule_ids: []) end 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/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb new file mode 100644 index 00000000000000..2719bbe83252be --- /dev/null +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ContextsController < Api::BaseController + include Authorization + include AsyncRefreshesConcern + + before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :set_status + + # This API was originally unlimited, pagination cannot be introduced without + # breaking backwards-compatibility. Arbitrarily high number to cover most + # conversations as quasi-unlimited, it would be too much work to render more + # than this anyway + CONTEXT_LIMIT = 4_096 + + # This remains expensive and we don't want to show everything to logged-out users + ANCESTORS_LIMIT = 40 + DESCENDANTS_LIMIT = 60 + DESCENDANTS_DEPTH_LIMIT = 20 + + def show + cache_if_unauthenticated! + + ancestors_limit = CONTEXT_LIMIT + descendants_limit = CONTEXT_LIMIT + descendants_depth_limit = nil + + if current_account.nil? + ancestors_limit = ANCESTORS_LIMIT + descendants_limit = DESCENDANTS_LIMIT + descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT + end + + ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) + descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) + loaded_ancestors = preload_collection(ancestors_results, Status) + loaded_descendants = preload_collection(descendants_results, Status) + + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) + statuses = [@status] + @context.ancestors + @context.descendants + + refresh_key = "context:#{@status.id}:refresh" + async_refresh = AsyncRefresh.new(refresh_key) + + if async_refresh.running? + add_async_refresh_header(async_refresh) + elsif !current_account.nil? && @status.should_fetch_replies? + add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true)) + + WorkerBatch.new.within do |batch| + batch.connect(refresh_key, threshold: 1.0) + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) + end + end + + render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + 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/interaction_policies_controller.rb b/app/controllers/api/v1/statuses/interaction_policies_controller.rb index 6e2745806d8d1b..5cfb2d0e8fd185 100644 --- a/app/controllers/api/v1/statuses/interaction_policies_controller.rb +++ b/app/controllers/api/v1/statuses/interaction_policies_controller.rb @@ -22,7 +22,7 @@ def status_params end def broadcast_updates! - DistributionWorker.perform_async(@status.id, { 'update' => true }) + DistributionWorker.perform_async(@status.id, { 'update' => true, 'skip_notifications' => true }) ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 }) end end diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb index 7107890af1e0a3..39662eff733188 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -26,20 +26,20 @@ def destroy def distribute_add_activity! json = ActiveModelSerializers::SerializableResource.new( @status, - serializer: ActivityPub::AddSerializer, + serializer: ActivityPub::AddNoteSerializer, adapter: ActivityPub::Adapter ).as_json - ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id) + ActivityPub::RawDistributionWorker.perform_async(json.to_json, current_account.id) end def distribute_remove_activity! json = ActiveModelSerializers::SerializableResource.new( @status, - serializer: ActivityPub::RemoveSerializer, + serializer: ActivityPub::RemoveNoteSerializer, adapter: ActivityPub::Adapter ).as_json - ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id) + ActivityPub::RawDistributionWorker.perform_async(json.to_json, current_account.id) end end diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb index 962855884ec87c..d851e55c293fef 100644 --- a/app/controllers/api/v1/statuses/quotes_controller.rb +++ b/app/controllers/api/v1/statuses/quotes_controller.rb @@ -4,13 +4,13 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke - before_action :check_owner! + before_action :set_statuses, only: :index + before_action :set_quote, only: :revoke after_action :insert_pagination_headers, only: :index def index cache_if_unauthenticated! - @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer end @@ -24,18 +24,26 @@ def revoke private - def check_owner! - authorize @status, :list_quotes? - end - def set_quote @quote = @status.quotes.find_by!(status_id: params[:id]) end - def load_statuses + def set_statuses scope = default_statuses scope = scope.not_excluded_by_account(current_account) unless current_account.nil? - scope.merge(paginated_quotes).to_a + @statuses = scope.merge(paginated_quotes).to_a + + # Store next page info before filtering + @records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + @pagination_since_id = @statuses.first.quote.id unless @statuses.empty? + @pagination_max_id = @statuses.last.quote.id if @records_continue + + if current_account&.id != @status.account_id + domains = @statuses.filter_map(&:account_domain).uniq + account_ids = @statuses.map(&:account_id).uniq + current_account&.preload_relations!(account_ids, domains) + @statuses.reject! { |status| StatusFilter.new(status, current_account).filtered? } + end end def default_statuses @@ -58,15 +66,9 @@ def prev_path api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? end - def pagination_max_id - @statuses.last.quote.id - end - - def pagination_since_id - @statuses.first.quote.id - end + attr_reader :pagination_max_id, :pagination_since_id def records_continue? - @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + @records_continue 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 6619899041fa95..d978501a02af28 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -2,14 +2,13 @@ class Api::V1::StatusesController < Api::BaseController include Authorization - include AsyncRefreshesConcern include Api::InteractionPoliciesConcern before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] - before_action :require_user!, except: [:index, :show, :context] + before_action :require_user!, except: [:index, :show] before_action :set_statuses, only: [:index] - before_action :set_status, only: [:show, :context] + before_action :set_status, only: [:show] before_action :set_thread, only: [:create] before_action :set_quoted_status, only: [:create] before_action :check_statuses_limit, only: [:index] @@ -17,17 +16,6 @@ class Api::V1::StatusesController < Api::BaseController override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :update, family: :statuses - # This API was originally unlimited, pagination cannot be introduced without - # breaking backwards-compatibility. Arbitrarily high number to cover most - # conversations as quasi-unlimited, it would be too much work to render more - # than this anyway - CONTEXT_LIMIT = 4_096 - - # This remains expensive and we don't want to show everything to logged-out users - ANCESTORS_LIMIT = 40 - DESCENDANTS_LIMIT = 60 - DESCENDANTS_DEPTH_LIMIT = 20 - def index @statuses = preload_collection(@statuses, Status) render json: @statuses, each_serializer: REST::StatusSerializer @@ -39,44 +27,6 @@ def show render json: @status, serializer: REST::StatusSerializer end - def context - cache_if_unauthenticated! - - ancestors_limit = CONTEXT_LIMIT - descendants_limit = CONTEXT_LIMIT - descendants_depth_limit = nil - - if current_account.nil? - ancestors_limit = ANCESTORS_LIMIT - descendants_limit = DESCENDANTS_LIMIT - descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT - end - - ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) - descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) - loaded_ancestors = preload_collection(ancestors_results, Status) - loaded_descendants = preload_collection(descendants_results, Status) - - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context.ancestors + @context.descendants - - refresh_key = "context:#{@status.id}:refresh" - async_refresh = AsyncRefresh.new(refresh_key) - - if async_refresh.running? - add_async_refresh_header(async_refresh) - elsif !current_account.nil? && @status.should_fetch_replies? - add_async_refresh_header(AsyncRefresh.create(refresh_key)) - - WorkerBatch.new.within do |batch| - batch.connect(refresh_key, threshold: 1.0) - ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) - end - end - - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) - end - def create @status = PostStatusService.new.call( current_user.account, @@ -93,6 +43,7 @@ def create application: doorkeeper_token.application, poll: status_params[:poll], content_type: status_params[:content_type], + local_only: status_params[:local_only], allowed_mentions: status_params[:allowed_mentions], idempotency: request.headers['Idempotency-Key'], with_rate_limit: true @@ -107,9 +58,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], @@ -117,9 +66,12 @@ def update language: status_params[:language], spoiler_text: status_params[:spoiler_text], poll: status_params[:poll], - quote_approval_policy: quote_approval_policy, - content_type: status_params[:content_type] - ) + content_type: status_params[:content_type], + } + + 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 @@ -128,10 +80,13 @@ def destroy @status = Status.where(account: current_account).find(params[:id]) authorize @status, :destroy? + # JSON is generated before `discard_with_reblogs` in order to have the proper URL + # for media attachments, as it would otherwise redirect to the media proxy + json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true + @status.discard_with_reblogs StatusPin.find_by(status: @status)&.destroy @status.account.statuses_count = @status.account.statuses_count - 1 - json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true RemovalWorker.perform_async(@status.id, { 'redraft' => !truthy_param?(:delete_media) }) @@ -147,7 +102,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 @@ -159,7 +114,7 @@ def set_thread end def set_quoted_status - @quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present? + @quoted_status = Status.find(status_params[:quoted_status_id])&.proper if status_params[:quoted_status_id].present? authorize(@quoted_status, :quote?) if @quoted_status.present? rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError # TODO: distinguish between non-existing and non-quotable posts @@ -190,6 +145,7 @@ def status_params :language, :scheduled_at, :content_type, + :local_only, allowed_mentions: [], media_ids: [], media_attributes: [ @@ -219,6 +175,6 @@ def unexpected_accounts_error_json(error) end def serialized_accounts(accounts) - ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer) + ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer, scope_name: :current_user, scope: current_user) end end diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb index 67a4d8ef492904..36822d831b16d5 100644 --- a/app/controllers/api/v1/tags_controller.rb +++ b/app/controllers/api/v1/tags_controller.rb @@ -39,6 +39,6 @@ def unfeature def set_or_create_tag return not_found unless Tag::HASHTAG_NAME_RE.match?(params[:id]) - @tag = Tag.find_normalized(params[:id]) || Tag.new(name: Tag.normalize(params[:id]), display_name: params[:id]) + @tag = Tag.find_normalized(params[:id]) || Tag.new(name: params[:id], display_name: params[:id]) end end diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb index 1dba4a5bb21d58..e79eba79ee575d 100644 --- a/app/controllers/api/v1/timelines/base_controller.rb +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -3,14 +3,8 @@ class Api::V1::Timelines::BaseController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - before_action :require_user!, if: :require_auth? - private - def require_auth? - !Setting.timeline_preview - end - def pagination_collection @statuses end diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index b8384a13687d73..a07faae7208ab2 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -3,8 +3,8 @@ class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController include AsyncRefreshesConcern - before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show] - before_action :require_user!, only: [:show] + before_action -> { doorkeeper_authorize! :read, :'read:statuses' } + before_action :require_user! PERMITTED_PARAMS = %i(local limit).freeze diff --git a/app/controllers/api/v1/timelines/link_controller.rb b/app/controllers/api/v1/timelines/link_controller.rb index 37ed084f0626ad..9e6ddd69243701 100644 --- a/app/controllers/api/v1/timelines/link_controller.rb +++ b/app/controllers/api/v1/timelines/link_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController +class Api::V1::Timelines::LinkController < Api::V1::Timelines::TopicController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :set_preview_card before_action :set_statuses diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index cd5445617be0ec..7110972dea4310 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :require_user!, if: :require_auth? PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze @@ -13,6 +14,16 @@ def show private + def require_auth? + if truthy_param?(:local) + Setting.local_live_feed_access != 'public' + elsif truthy_param?(:remote) + Setting.remote_live_feed_access != 'public' + else + Setting.local_live_feed_access != 'public' || Setting.remote_live_feed_access != 'public' + end + end + def load_statuses preloaded_public_statuses_page end diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 2b097aab0f85b8..dc3c6a72157ba7 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController +class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :load_tag @@ -14,10 +14,6 @@ def show private - def require_auth? - !Setting.timeline_preview - end - def load_tag @tag = Tag.find_normalized(params[:id]) end diff --git a/app/controllers/api/v1/timelines/topic_controller.rb b/app/controllers/api/v1/timelines/topic_controller.rb new file mode 100644 index 00000000000000..6faf54f708311f --- /dev/null +++ b/app/controllers/api/v1/timelines/topic_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::TopicController < Api::V1::Timelines::BaseController + before_action :require_user!, if: :require_auth? + + private + + def require_auth? + if truthy_param?(:local) + Setting.local_topic_feed_access != 'public' + elsif truthy_param?(:remote) + Setting.remote_topic_feed_access != 'public' + else + Setting.local_topic_feed_access != 'public' || Setting.remote_topic_feed_access != 'public' + end + end +end diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb index 637587967fe328..de20dd071e9c7a 100644 --- a/app/controllers/api/v2/notifications/policies_controller.rb +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -32,7 +32,8 @@ def resource_params :for_not_followers, :for_new_accounts, :for_private_mentions, - :for_limited_accounts + :for_limited_accounts, + :for_bots ) end end diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb index 848c361cfc321f..f54cd230b7aaa1 100644 --- a/app/controllers/api/v2/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -33,7 +33,7 @@ def index 'app.notification_grouping.expand_accounts_param' => expand_accounts_param ) - render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param, supported_notification_types: params[:supported_types] end end @@ -48,7 +48,7 @@ def unread_count def show @notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take! presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification])) - render json: presenter, serializer: REST::DedupNotificationGroupSerializer + render json: presenter, serializer: REST::DedupNotificationGroupSerializer, supported_notification_types: params[:supported_types] end def clear @@ -138,7 +138,9 @@ def browserable_params end def pagination_params(core_params) - params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params) + params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types, :supported_types) + .permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: [], supported_types: []) + .merge(core_params) end def expand_accounts_param 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 205ce614770197..deb60f69bc7f8f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,39 +9,21 @@ class ApplicationController < ActionController::Base include UserTrackingConcern include SessionTrackingConcern include CacheConcern + include ErrorResponses include PreloadingConcern include DomainControlHelper - include ThemingConcern include DatabaseHelper include AuthorizedFetchHelper include SelfDestructHelper helper_method :current_account helper_method :current_session - helper_method :current_flavour - helper_method :current_skin - helper_method :current_theme helper_method :single_user_mode? helper_method :use_seamless_external_login? helper_method :sso_account_settings helper_method :limited_federation_mode? helper_method :skip_csrf_meta_tags? - rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request - rescue_from Mastodon::NotPermittedError, with: :forbidden - rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found - rescue_from ActionController::UnknownFormat, with: :not_acceptable - rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content - rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests - - rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) - rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable - - rescue_from Seahorse::Client::NetworkingError do |e| - Rails.logger.warn "Storage server error: #{e}" - service_unavailable - end - before_action :check_self_destruct! before_action :store_referrer, except: :raise_not_found, if: :devise_controller? @@ -65,19 +47,25 @@ def store_referrer return if request.referer.blank? redirect_uri = URI(request.referer) - return if redirect_uri.path.start_with?('/auth') + return if redirect_uri.path.start_with?('/auth', '/settings/two_factor_authentication', '/settings/otp_authentication') stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port store_location_for(:user, stored_url) end + def mfa_setup_path(path_params = {}) + settings_two_factor_authentication_methods_path(path_params) + end + def require_functional! return if current_user.functional? respond_to do |format| format.any do - if current_user.confirmed? + if current_user.missing_2fa? + redirect_to mfa_setup_path + elsif current_user.confirmed? redirect_to edit_user_registration_path else redirect_to auth_setup_path @@ -89,6 +77,8 @@ def require_functional! render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 elsif !current_user.approved? render json: { error: 'Your login is currently pending approval' }, status: 403 + elsif current_user.missing_2fa? + render json: { error: 'Your account requires two-factor authentication' }, status: 403 elsif !current_user.functional? render json: { error: 'Your login is currently disabled' }, status: 403 end @@ -114,42 +104,6 @@ def truthy_param?(key) ActiveModel::Type::Boolean.new.cast(params[key]) end - def forbidden - respond_with_error(403) - end - - def not_found - respond_with_error(404) - end - - def gone - respond_with_error(410) - end - - def unprocessable_content - respond_with_error(422) - end - - def not_acceptable - respond_with_error(406) - end - - def bad_request - respond_with_error(400) - end - - def internal_server_error - respond_with_error(500) - end - - def service_unavailable - respond_with_error(503) - end - - def too_many_requests - respond_with_error(429) - end - def single_user_mode? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.without_internal.exists? end @@ -174,13 +128,6 @@ def current_session @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? end - def respond_with_error(code) - respond_to do |format| - format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } - format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } - end - end - def check_self_destruct! return unless self_destruct? diff --git a/app/controllers/auth/acceptances_controller.rb b/app/controllers/auth/acceptances_controller.rb new file mode 100644 index 00000000000000..ba03a327e7bd31 --- /dev/null +++ b/app/controllers/auth/acceptances_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Auth::AcceptancesController < ApplicationController + def create + redirect_to new_user_registration_path(registration_params) + end + + private + + def registration_params + params.permit(:accept, :invite_code).compact_blank + end +end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index fc430544fbef50..b315b273d58b09 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -89,7 +89,7 @@ def after_update_path_for(_resource) end def check_enabled_registrations - redirect_to root_path unless allowed_registration?(request.remote_ip, @invite) + redirect_to new_user_session_path, alert: I18n.t('devise.failure.closed_registrations', email: Setting.site_contact_email) unless allowed_registration?(request.remote_ip, @invite) end def invite_code @@ -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/security_key_options_controller.rb b/app/controllers/auth/sessions/security_key_options_controller.rb new file mode 100644 index 00000000000000..1bd2b4043c7e1c --- /dev/null +++ b/app/controllers/auth/sessions/security_key_options_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Auth::Sessions::SecurityKeyOptionsController < ApplicationController + skip_before_action :check_self_destruct! + skip_before_action :require_functional! + skip_before_action :update_user_sign_in + + def show + user = User.find_by(id: session[:attempt_user_id]) + + if user&.webauthn_enabled? + options_for_get = WebAuthn::Credential.options_for_get( + allow: user.webauthn_credentials.pluck(:external_id), + user_verification: 'discouraged' + ) + + session[:webauthn_challenge] = options_for_get.challenge + + render json: options_for_get, status: 200 + else + render json: { error: t('webauthn_credentials.not_enabled') }, status: 401 + end + end +end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 182f242ae5b521..67bee2344ef5ea 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -38,23 +38,6 @@ def destroy flash.delete(:notice) end - def webauthn_options - user = User.find_by(id: session[:attempt_user_id]) - - if user&.webauthn_enabled? - options_for_get = WebAuthn::Credential.options_for_get( - allow: user.webauthn_credentials.pluck(:external_id), - user_verification: 'discouraged' - ) - - session[:webauthn_challenge] = options_for_get.challenge - - render json: options_for_get, status: 200 - else - render json: { error: t('webauthn_credentials.not_enabled') }, status: 401 - end - end - protected def find_user @@ -197,14 +180,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..31fbb82f019f58 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -7,10 +7,13 @@ class AuthorizeInteractionsController < ApplicationController before_action :set_resource def show - if @resource.is_a?(Account) + case @resource + when Account redirect_to web_url("@#{@resource.pretty_acct}") - elsif @resource.is_a?(Status) + when Status redirect_to web_url("@#{@resource.account.pretty_acct}/#{@resource.id}") + when Collection + redirect_to web_url("collections/#{@resource.id}") else not_found end @@ -21,7 +24,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/collection_items_controller.rb b/app/controllers/collection_items_controller.rb new file mode 100644 index 00000000000000..51044b5965456e --- /dev/null +++ b/app/controllers/collection_items_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class CollectionItemsController < ApplicationController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :require_account_signature!, if: -> { authorized_fetch_mode? } + before_action :set_collection_item + + skip_around_action :set_locale + skip_before_action :require_functional!, unless: :limited_federation_mode? + + def show + respond_to do |format| + format.json do + expires_in(3.minutes, public: public_fetch_mode?) + + render json: @collection_item, + serializer: ActivityPub::FeaturedItemSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_collection_item + @collection_item = @account.curated_collection_items.find(params[:id]) + authorize @collection_item.collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb new file mode 100644 index 00000000000000..46f9badb89508d --- /dev/null +++ b/app/controllers/collections_controller.rb @@ -0,0 +1,57 @@ +# 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 :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :set_collection + before_action :redirect_to_canonical_url + + 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| + format.html do + expires_in expiration_duration, public: true unless user_signed_in? + end + + 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_account + if account_id_param.present? + @account = Account.local.find(account_id_param) + else + @collection = Collection.find(params[:id]) + @account = @collection.account + end + end + + def set_collection + @collection ||= @account.collections.find(params[:id]) + authorize @collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end + + def redirect_to_canonical_url + redirect_to collection_path(@collection) if request.format.html? && request.path.starts_with?('/ap/') + end + + def expiration_duration + recently_updated = @collection.updated_at > 15.minutes.ago + recently_updated ? 30.seconds : 5.minutes + end +end diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb index 2b132417f7cf33..7b3cd4d3ea607c 100644 --- a/app/controllers/concerns/account_owned_concern.rb +++ b/app/controllers/concerns/account_owned_concern.rb @@ -18,7 +18,11 @@ def account_required? end def set_account - @account = Account.find_local!(username_param) + @account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param) + end + + def account_id_param + params[:account_id] end def username_param 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/async_refreshes_concern.rb b/app/controllers/concerns/async_refreshes_concern.rb index 29122e16b5e14e..2d0e9ff4ff4ba5 100644 --- a/app/controllers/concerns/async_refreshes_concern.rb +++ b/app/controllers/concerns/async_refreshes_concern.rb @@ -6,6 +6,9 @@ module AsyncRefreshesConcern def add_async_refresh_header(async_refresh, retry_seconds: 3) return unless async_refresh.running? - response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}" + value = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}" + value += ", result_count=#{async_refresh.result_count}" unless async_refresh.result_count.nil? + + response.headers['Mastodon-Async-Refresh'] = value end end 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/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index 7fbc469bdf1386..bd97037da600fa 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -42,7 +42,7 @@ def require_challenge! end def render_challenge - render 'auth/challenges/new', layout: 'auth' + render 'auth/challenges/new', layout: params[:oauth] ? 'modal' : 'auth' end def challenge_passed? diff --git a/app/controllers/concerns/error_responses.rb b/app/controllers/concerns/error_responses.rb new file mode 100644 index 00000000000000..402ade0066a098 --- /dev/null +++ b/app/controllers/concerns/error_responses.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module ErrorResponses + extend ActiveSupport::Concern + + included do + rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content + rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request + rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActionController::UnknownFormat, with: :not_acceptable + rescue_from Mastodon::NotPermittedError, with: :forbidden + rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable + rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests + rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) + + rescue_from Seahorse::Client::NetworkingError do |e| + Rails.logger.warn "Storage server error: #{e}" + service_unavailable + end + end + + protected + + def bad_request + respond_with_error(400) + end + + def forbidden + respond_with_error(403) + end + + def gone + respond_with_error(410) + end + + def internal_server_error + respond_with_error(500) + end + + def not_acceptable + respond_with_error(406) + end + + def not_found + respond_with_error(404) + end + + def service_unavailable + respond_with_error(503) + end + + def too_many_requests + respond_with_error(429) + end + + def unprocessable_content + respond_with_error(422) + end + + private + + def respond_with_error(code) + respond_to do |format| + format.any { render "errors/#{code}", layout: 'error', formats: [:html], status: code } + format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } + end + end +end diff --git a/app/controllers/concerns/settings/export_controller_concern.rb b/app/controllers/concerns/settings/export_controller_concern.rb index 2cf28cced87234..9917a5bd0495cb 100644 --- a/app/controllers/concerns/settings/export_controller_concern.rb +++ b/app/controllers/concerns/settings/export_controller_concern.rb @@ -19,15 +19,12 @@ def load_export def send_export_file respond_to do |format| - format.csv { send_data export_data, filename: export_filename } + format.csv { send_data export_data, filename: "#{controller_name}.csv" } + format.json { send_data export_data, filename: "#{controller_name}.json" } end end def export_data raise 'Override in controller' end - - def export_filename - "#{controller_name}.csv" - end end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 2bdd3558643526..1a876b0cadef70 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -53,29 +53,34 @@ def signed_request_actor raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request? - actor = actor_from_key_id + keypair = keypair_from_key_id - raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_key_id}" if actor.nil? + raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_key_id}" if keypair.nil? - return (@signed_request_actor = actor) if signed_request.verified?(actor) + check_keypair_validity!(keypair) + return (@signed_request_actor = keypair.actor) if signed_request.verified?(keypair) - actor = stoplight_wrapper.run { actor_refresh_key!(actor) } + keypair = stoplight_wrapper.run { keypair_refresh_key!(keypair) } - raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_key_id}" if actor.nil? + raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_key_id}" if keypair.nil? - return (@signed_request_actor = actor) if signed_request.verified?(actor) + check_keypair_validity!(keypair) + return (@signed_request_actor = keypair.actor) if signed_request.verified?(keypair) - fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}" + fail_with! "Verification failed for #{keypair.actor.to_log_human_identifier} #{keypair.actor.uri} #{keypair.uri}" rescue Mastodon::MalformedHeaderError => e @signature_verification_failure_code = 400 fail_with! e.message 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 @@ -86,7 +91,7 @@ def fail_with!(message, **options) @signed_request_actor = nil end - def actor_from_key_id + def keypair_from_key_id key_id = signed_request.key_id domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id @@ -96,11 +101,12 @@ def actor_from_key_id end if key_id.start_with?('acct:') - stoplight_wrapper.run { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } + stoplight_wrapper.run { fetch_key_from_acct(key_id.delete_prefix('acct:')) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) - account = ActivityPub::TagManager.instance.uri_to_actor(key_id) - account ||= stoplight_wrapper.run { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } - account + keypair = Keypair.from_keyid(key_id) + return keypair if keypair.present? + + stoplight_wrapper.run { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } end rescue Mastodon::PrivateNetworkAddressError => e raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" @@ -108,6 +114,15 @@ def actor_from_key_id raise Mastodon::SignatureVerificationError, e.message end + def fetch_key_from_acct(acct) + # This is legacy and can't let us pick a specific key, so pick the first + + account = ResolveAccountService.new.call(acct, suppress_errors: false) + return if account.nil? + + account.keypairs.first || Keypair.from_legacy_account(account) + end + def stoplight_wrapper Stoplight( "source:#{request.remote_ip}", @@ -117,14 +132,32 @@ def stoplight_wrapper ) end - def actor_refresh_key!(actor) - return if actor.local? || !actor.activitypub? - return actor.refresh! if actor.respond_to?(:refresh!) && actor.possibly_stale? + def keypair_refresh_key!(keypair) + return if keypair.actor.local? || !keypair.actor.activitypub? - ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false) + actor = if keypair.actor.possibly_stale? + # Doing a full profile refresh + keypair.actor.refresh! + else + # Only refreshing keys, skipping potentially more expensive requests + ActivityPub::FetchRemoteActorService.new.call(keypair.actor.uri, only_key: true, suppress_errors: false) + end + return if actor.nil? + + keypair_uri = keypair.uri + + keypair = actor.keypairs.find_by(uri: keypair_uri) + return keypair if keypair.present? + + Keypair.from_legacy_account(actor, uri: keypair_uri) if actor.public_key.present? rescue Mastodon::PrivateNetworkAddressError => e raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})" rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e raise Mastodon::SignatureVerificationError, e.message end + + def check_keypair_validity!(keypair) + raise Mastodon::SignatureVerificationError, "Key #{signature_key_id} is revoked" if keypair.revoked? + raise Mastodon::SignatureVerificationError, "Key #{signature_key_id} has expired" if keypair.expired? + end end diff --git a/app/controllers/concerns/theming_concern.rb b/app/controllers/concerns/theming_concern.rb deleted file mode 100644 index 38b31e932ff396..00000000000000 --- a/app/controllers/concerns/theming_concern.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module ThemingConcern - extend ActiveSupport::Concern - - private - - def current_flavour - @current_flavour ||= [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) } - end - - def current_skin - @current_skin ||= begin - skins = Themes.instance.skins_for(current_flavour) - [current_user&.setting_skin, Setting.skin, 'system', 'default'].find { |skin| skins.include?(skin) } - end - end - - def current_theme - # NOTE: this is slightly different from upstream, as it's a derived value used - # for the sole purpose of pointing to the appropriate stylesheet pack - [current_flavour, current_skin] - end -end diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index 5b98914114473e..21fa8d4fe1b879 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -3,7 +3,7 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController def show expires_in 1.month, public: true - render content_type: 'text/css' + render content_type: :css end private diff --git a/app/controllers/disputes/appeals_controller.rb b/app/controllers/disputes/appeals_controller.rb index 797f31cf782b7f..00034e1ab2d7b6 100644 --- a/app/controllers/disputes/appeals_controller.rb +++ b/app/controllers/disputes/appeals_controller.rb @@ -8,7 +8,7 @@ def create @appeal = AppealService.new.call(@strike, appeal_params[:text]) - redirect_to disputes_strike_path(@strike), notice: I18n.t('disputes.strikes.appealed_msg') + redirect_to admin_disputes_strike_path(@strike), notice: I18n.t('disputes.strikes.appealed_msg') rescue ActiveRecord::RecordInvalid => e @appeal = e.record render 'disputes/strikes/show' diff --git a/app/controllers/email_subscriptions/confirmations_controller.rb b/app/controllers/email_subscriptions/confirmations_controller.rb new file mode 100644 index 00000000000000..2750b68d4fc723 --- /dev/null +++ b/app/controllers/email_subscriptions/confirmations_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class EmailSubscriptions::ConfirmationsController < ApplicationController + layout 'auth' + + before_action :set_email_subscription + + def show + @email_subscription.confirm! unless @email_subscription.confirmed? + end + + private + + def set_email_subscription + @email_subscription = EmailSubscription.find_by!(confirmation_token: params[:confirmation_token]) + end +end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index ab8d77f353e396..909129c11b152f 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -7,6 +7,7 @@ class FollowerAccountsController < ApplicationController vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :protect_hidden_collections, if: -> { request.format.json? } skip_around_action :set_locale, if: -> { request.format == :json } skip_before_action :require_functional!, unless: :limited_federation_mode? @@ -18,8 +19,6 @@ def index end format.json do - raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections? - expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: collection_presenter, @@ -41,6 +40,10 @@ def follows @follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) end + def protect_hidden_collections + raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections? + end + def page_requested? params[:page].present? end @@ -58,20 +61,22 @@ def prev_page_url end def collection_presenter - options = { type: :ordered } + options = {} options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count if page_requested? ActivityPub::CollectionPresenter.new( - id: account_followers_url(@account, page: params.fetch(:page, 1)), + id: page_url(params.fetch(:page, 1)), + type: :ordered, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) }, - part_of: account_followers_url(@account), + part_of: ActivityPub::TagManager.instance.followers_uri_for(@account), next: next_page_url, prev: prev_page_url, **options ) else ActivityPub::CollectionPresenter.new( - id: account_followers_url(@account), + id: ActivityPub::TagManager.instance.followers_uri_for(@account), + type: :ordered, first: page_url(1), **options ) diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 268fad96d09b68..7a0f37887de320 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -7,6 +7,7 @@ class FollowingAccountsController < ApplicationController vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :protect_hidden_collections, if: -> { request.format.json? } skip_around_action :set_locale, if: -> { request.format == :json } skip_before_action :require_functional!, unless: :limited_federation_mode? @@ -18,11 +19,6 @@ def index end format.json do - if page_requested? && @account.hide_collections? - forbidden - next - end - expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: collection_presenter, @@ -44,12 +40,16 @@ def follows @follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) end + def protect_hidden_collections + raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections? + end + def page_requested? params[:page].present? end def page_url(page) - account_following_index_url(@account, page: page) unless page.nil? + ActivityPub::TagManager.instance.following_uri_for(@account, page: page) unless page.nil? end def next_page_url @@ -63,17 +63,17 @@ def prev_page_url def collection_presenter if page_requested? ActivityPub::CollectionPresenter.new( - id: account_following_index_url(@account, page: params.fetch(:page, 1)), + id: page_url(params.fetch(:page, 1)), type: :ordered, size: @account.following_count, items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) }, - part_of: account_following_index_url(@account), + part_of: ActivityPub::TagManager.instance.following_uri_for(@account), next: next_page_url, prev: prev_page_url ) else ActivityPub::CollectionPresenter.new( - id: account_following_index_url(@account), + id: ActivityPub::TagManager.instance.following_uri_for(@account), type: :ordered, size: @account.following_count, first: page_url(1) diff --git a/app/controllers/mail_subscriptions_controller.rb b/app/controllers/mail_subscriptions_controller.rb deleted file mode 100644 index 34df75f63ad6c4..00000000000000 --- a/app/controllers/mail_subscriptions_controller.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class MailSubscriptionsController < ApplicationController - layout 'auth' - - skip_before_action :require_functional! - - before_action :set_user - before_action :set_type - - protect_from_forgery with: :null_session - - def show; end - - def create - @user.settings[email_type_from_param] = false - @user.save! - end - - private - - def set_user - @user = GlobalID::Locator.locate_signed(params[:token], for: 'unsubscribe') - not_found unless @user - end - - def set_type - @type = email_type_from_param - end - - def email_type_from_param - case params[:type] - when 'follow', 'reblog', 'favourite', 'mention', 'follow_request' - "notification_emails.#{params[:type]}" - else - not_found - end - end -end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 9d10468e69330e..2aa83717c38ffa 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -24,17 +24,12 @@ def player; end private def set_media_attachment - id = params[:id] || params[:medium_id] - return if id.nil? - - scope = MediaAttachment.local.attached - # If id is 19 characters long, it's a shortcode, otherwise it's an identifier - @media_attachment = id.size == 19 ? scope.find_by!(shortcode: id) : scope.find(id) + @media_attachment = MediaAttachment.local.attached.identified(params[:id]) end 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..8b3b41e72fed25 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,17 +17,15 @@ 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) ActiveModel::Type::Boolean.new.cast(params[key]) end + + def mfa_setup_path + super({ oauth: true }) + end end diff --git a/app/controllers/redirect/collections_controller.rb b/app/controllers/redirect/collections_controller.rb new file mode 100644 index 00000000000000..f5e177d102366c --- /dev/null +++ b/app/controllers/redirect/collections_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Redirect::CollectionsController < Redirect::BaseController + private + + def set_resource + @resource = Collection.find(params[:id]) + not_found if @resource.local? || @resource&.account&.suspended? + end +end diff --git a/app/controllers/settings/exports/custom_filters_controller.rb b/app/controllers/settings/exports/custom_filters_controller.rb new file mode 100644 index 00000000000000..ec7c0dfa040713 --- /dev/null +++ b/app/controllers/settings/exports/custom_filters_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Settings + module Exports + class CustomFiltersController < BaseController + include Settings::ExportControllerConcern + + def index + send_export_file + end + + private + + def export_data + @export.to_custom_filters_json + end + end + end +end diff --git a/app/controllers/settings/flavours_controller.rb b/app/controllers/settings/flavours_controller.rb index b179b9429f5d70..ef194784f4f824 100644 --- a/app/controllers/settings/flavours_controller.rb +++ b/app/controllers/settings/flavours_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Settings::FlavoursController < Settings::BaseController + include ThemeHelper + layout 'admin' before_action :authenticate_user! diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index be1699315f6f38..a8dc31b7c74e3b 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -13,6 +13,7 @@ class Settings::ImportsController < Settings::BaseController domain_blocking: 'blocked_domains_failures.csv', bookmarks: 'bookmarks_failures.csv', lists: 'lists_failures.csv', + custom_filters: 'custom_filters_failures.json', }.freeze TYPE_TO_HEADERS_MAP = { @@ -61,6 +62,21 @@ def failures send_data export_data, filename: filename end + + format.json do + filename = TYPE_TO_FILENAME_MAP[@bulk_import.type.to_sym] + + data_collection = { custom_filters: [] } + @bulk_import.rows.find_each do |row| + case @bulk_import.type.to_sym + when :custom_filters + data_collection[:custom_filters] << row.data + end + end + export_data = JSON.generate(data_collection) + + send_data export_data, filename: filename + end end end diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb index 96efa03ccf7e3d..2716fce806e99c 100644 --- a/app/controllers/settings/privacy_controller.rb +++ b/app/controllers/settings/privacy_controller.rb @@ -2,6 +2,7 @@ class Settings::PrivacyController < Settings::BaseController before_action :set_account + before_action :set_email_subscriptions_count def show; end @@ -24,4 +25,8 @@ def account_params def set_account @account = current_account end + + def set_email_subscriptions_count + @email_subscriptions_count = with_read_replica { @account.email_subscriptions.confirmed.count } + end end diff --git a/app/controllers/settings/two_factor_authentication/base_controller.rb b/app/controllers/settings/two_factor_authentication/base_controller.rb new file mode 100644 index 00000000000000..8770f927e76e4e --- /dev/null +++ b/app/controllers/settings/two_factor_authentication/base_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Settings + module TwoFactorAuthentication + class BaseController < ::Settings::BaseController + layout -> { truthy_param?(:oauth) ? 'modal' : 'admin' } + end + end +end diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index eae990e79b2229..61e2aef5a8161c 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -4,12 +4,15 @@ module Settings module TwoFactorAuthentication class ConfirmationsController < BaseController include ChallengableConcern + include Devise::Controllers::StoreLocation skip_before_action :require_functional! before_action :require_challenge! before_action :ensure_otp_secret + helper_method :return_to_app_url + def new prepare_two_factor_form end @@ -37,6 +40,10 @@ def create private + def return_to_app_url + stored_location_for(:user) + end + def confirmation_params params.expect(form_two_factor_confirmation: [:otp_attempt]) end diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb index ca8d46afe48199..5460448d995da3 100644 --- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -17,7 +17,7 @@ def show def create session[:new_otp_secret] = User.generate_otp_secret - redirect_to new_settings_two_factor_authentication_confirmation_path + redirect_to new_settings_two_factor_authentication_confirmation_path(params.permit(:oauth)) end private diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb index 6ec53224d384c3..565409612c4ff3 100644 --- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb +++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb @@ -7,7 +7,7 @@ class RecoveryCodesController < BaseController skip_before_action :require_functional! - before_action :require_challenge!, on: :create + before_action :require_challenge! def create @recovery_codes = current_user.generate_otp_backup_codes! diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb index a6d5c1fe2dd4f5..49579b36779965 100644 --- a/app/controllers/settings/two_factor_authentication_methods_controller.rb +++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb @@ -22,7 +22,7 @@ def disable private def require_otp_enabled - redirect_to settings_otp_authentication_path unless current_user.otp_enabled? + redirect_to settings_otp_authentication_path(params.permit(:oauth)) unless current_user.otp_enabled? end end end diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb index 817abebf62d1da..a5373685301fe0 100644 --- a/app/controllers/severed_relationships_controller.rb +++ b/app/controllers/severed_relationships_controller.rb @@ -13,20 +13,20 @@ def index def following respond_to do |format| - format.csv { send_data following_data, filename: "following-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + format.csv { send_data following_data, filename: } end end def followers respond_to do |format| - format.csv { send_data followers_data, filename: "followers-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + format.csv { send_data followers_data, filename: } end end private def set_event - @event = AccountRelationshipSeveranceEvent.find(params[:id]) + @event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id]) end def following_data @@ -48,4 +48,8 @@ def followers_data def acct(account) account.local? ? account.local_username_and_domain : account.acct end + + def filename + "#{action_name}-#{@event.identifier}.csv" + end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index af6bebf36fd753..7b1f63da6c5013 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -26,10 +26,12 @@ def show respond_to do |format| format.html do expires_in 10.seconds, public: true if current_account.nil? + + redirect_to short_account_status_path(@account, @status) if account_id_param.present? && username_param.blank? 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 +39,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 +64,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/unsubscriptions_controller.rb b/app/controllers/unsubscriptions_controller.rb new file mode 100644 index 00000000000000..aac58a38068bc4 --- /dev/null +++ b/app/controllers/unsubscriptions_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class UnsubscriptionsController < ApplicationController + layout 'auth' + + skip_before_action :require_functional! + + before_action :set_recipient + before_action :set_type + before_action :set_scope + before_action :require_type_if_user! + + protect_from_forgery with: :null_session + + def show; end + + def create + case @scope + when :user + @recipient.settings[@type] = false + @recipient.save! + when :email_subscription + @recipient.destroy! + end + end + + private + + def set_recipient + @recipient = GlobalID::Locator.locate_signed(params[:token], for: 'unsubscribe') + not_found unless @recipient + end + + def set_scope + if @recipient.is_a?(User) + @scope = :user + elsif @recipient.is_a?(EmailSubscription) + @scope = :email_subscription + else + not_found + end + end + + def set_type + @type = email_type_from_param + end + + def require_type_if_user! + not_found if @recipient.is_a?(User) && @type.blank? + end + + def email_type_from_param + case params[:type] + when 'follow', 'reblog', 'favourite', 'mention', 'follow_request' + "notification_emails.#{params[:type]}" + end + end +end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 72f0ea890fc654..9536b948f3c1e4 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -18,23 +18,7 @@ def show private def set_account - username = username_from_resource - - @account = begin - if username == Rails.configuration.x.local_domain || username == Rails.configuration.x.web_domain - Account.representative - else - Account.find_local!(username) - end - end - end - - def username_from_resource - resource_user = resource_param - username, domain = resource_user.split('@') - resource_user = "#{username}@#{Rails.configuration.x.local_domain}" if Rails.configuration.x.alternate_domains.include?(domain) - - WebfingerResource.new(resource_user).username + @account = WebfingerResource.new(resource_param).account end def resource_param 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/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 4a55a36ecd1c40..76edb965a6aa38 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -19,7 +19,7 @@ def log_target(log) link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance') - when 'Status' + when 'Status', 'Collection' link_to log.human_identifier, log.permalink when 'AccountWarning' link_to log.human_identifier, disputes_strike_path(log.target_id) diff --git a/app/helpers/admin/content_policies_helper.rb b/app/helpers/admin/content_policies_helper.rb new file mode 100644 index 00000000000000..11c1109ed41f75 --- /dev/null +++ b/app/helpers/admin/content_policies_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Admin::ContentPoliciesHelper + def policy_list(domain_block) + domain_block + .policies + .map { |policy| I18n.t("admin.instances.content_policies.policies.#{policy}") } + .join(' · ') + end +end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 40806a451586df..a3dd83ecca60aa 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -20,8 +20,9 @@ module Admin::FilterHelper def filter_link_to(text, link_to_params, link_class_params = link_to_params) new_url = filtered_url_for(link_to_params) new_class = filtered_url_for(link_class_params) + is_selected = selected?(link_class_params) - link_to text, new_url, class: filter_link_class(new_class) + link_to text, new_url, class: filter_link_class(new_class), 'aria-current': (is_selected ? 'true' : nil) end def table_link_to(icon, text, path, **options) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e759c7e3bbffc5..b57a2da5a0f9a4 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -34,11 +34,11 @@ def closed_registrations? Setting.registrations_mode == 'none' end - def available_sign_up_path + def available_sign_up_url if closed_registrations? || omniauth_only? - 'https://joinmastodon.org/#getting-started' + 'https://joinmastodon.org/' else - ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_path) + ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_url) end end @@ -113,6 +113,7 @@ def conditional_link_to(condition, name, options = {}, html_options = {}, &block end def material_symbol(icon, attributes = {}) + whitespace = attributes.delete(:whitespace) { true } safe_join( [ inline_svg_tag( @@ -121,11 +122,15 @@ def material_symbol(icon, attributes = {}) role: :img, data: attributes[:data] ), - ' ', + whitespace ? ' ' : '', ] ) end + def emptyphaunt + inline_svg_tag 'elephant_ui.svg' + end + def check_icon inline_svg_tag 'check.svg' end @@ -152,11 +157,23 @@ 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, + 'data-user-flavour': current_flavour.parameterize, + } + + base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto' + + base + end + + def html_classes output = [] - output << content_for(:body_classes) - output << "flavour-#{current_flavour.parameterize}" - output << "skin-#{current_skin.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 +181,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 @@ -266,8 +289,8 @@ def within_authorization_flow? end # glitch-soc addition to handle the multiple flavors - def flavoured_vite_typescript_tag(pack_name, **) - vite_typescript_tag("#{Themes.instance.flavour(current_flavour)['pack_directory'].delete_prefix('app/javascript/')}/#{pack_name}", **) + def flavoured_vite_typescript_tag(pack_name, flavour: nil, **) + vite_typescript_tag("#{Themes.instance.flavour(flavour || current_flavour)['pack_directory'].delete_prefix('app/javascript/')}/#{pack_name}", **) end private diff --git a/app/helpers/branding_helper.rb b/app/helpers/branding_helper.rb index 8201f36e3c2d1b..ef6d33ae5ce3d4 100644 --- a/app/helpers/branding_helper.rb +++ b/app/helpers/branding_helper.rb @@ -11,11 +11,14 @@ def logo_as_symbol(version = :icon) end def _logo_as_symbol_wordmark - content_tag(:svg, tag.use(href: '#logo-symbol-wordmark'), viewBox: '0 0 261 66', class: 'logo logo--wordmark') + tag.svg(viewBox: '0 0 261 66', class: 'logo logo--wordmark') do + tag.title('Mastodon') + + tag.use(href: '#logo-symbol-wordmark') + end end def _logo_as_symbol_icon - content_tag(:svg, tag.use(href: '#logo-symbol-icon'), viewBox: '0 0 79 79', class: 'logo logo--icon') + tag.svg(tag.use(href: '#logo-symbol-icon'), viewBox: '0 0 79 79', class: 'logo logo--icon') end def render_logo diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index fae76edf25c31c..6499da7240fb89 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -4,6 +4,8 @@ module ContextHelper NAMED_CONTEXT_MAP = { activitystreams: 'https://www.w3.org/ns/activitystreams', security: 'https://w3id.org/security/v1', + controlled_identifiers: 'https://www.w3.org/ns/cid/v1', + webfinger: 'https://purl.archive.org/socialweb/webfinger', }.freeze CONTEXT_EXTENSION_MAP = { @@ -25,10 +27,16 @@ module ContextHelper memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, - attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, + attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@container' => '@set' } }, + profile_settings: { + 'toot' => 'http://joinmastodon.org/ns#', + 'showFeatured' => 'toot:showFeatured', + 'showMedia' => 'toot:showMedia', + 'showRepliesInMedia' => 'toot:showRepliesInMedia', + }, quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, quotes: { - 'quote' => 'https://w3id.org/fep/044f#quote', + 'quote' => { '@id' => 'https://w3id.org/fep/044f#quote', '@type' => '@id' }, 'quoteUri' => 'http://fedibird.com/ns#quoteUri', '_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote', 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' }, @@ -36,15 +44,32 @@ module ContextHelper interaction_policies: { 'gts' => 'https://gotosocial.org/ns#', 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, + 'canFeature' => { '@id' => 'https://w3id.org/fep/7aa9#canFeature', '@type' => '@id' }, 'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' }, 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' }, 'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' }, }, quote_authorizations: { 'gts' => 'https://gotosocial.org/ns#', - 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' }, - 'interactingObject' => { '@id' => 'gts:interactingObject' }, - 'interactionTarget' => { '@id' => 'gts:interactionTarget' }, + 'QuoteAuthorization' => 'https://w3id.org/fep/044f#QuoteAuthorization', + 'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' }, + 'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' }, + }, + feature_requests: { 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest' }, + featured_collections: { + 'FeaturedCollection' => 'https://w3id.org/fep/7aa9#FeaturedCollection', + 'FeaturedItem' => 'https://w3id.org/fep/7aa9#FeaturedItem', + 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest', + 'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization', + 'topic' => { '@id' => 'https://w3id.org/fep/7aa9#topic', '@type' => '@id' }, + 'featuredObject' => { '@id' => 'https://w3id.org/fep/7aa9#featuredObject', '@type' => '@id' }, + 'featureAuthorization' => { '@id' => 'https://w3id.org/fep/7aa9#featureAuthorization', '@type' => '@id' }, + }, + feature_authorizations: { + 'gts' => 'https://gotosocial.org/ns#', + 'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization', + 'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' }, + 'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' }, }, }.freeze 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/home_helper.rb b/app/helpers/home_helper.rb index 79e28c983af40f..59bc06031eea1a 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -21,7 +21,13 @@ def account_link_to(account, button = '', path: nil) end end else - link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do + account_url = if account.suspended? + ActivityPub::TagManager.instance.url_for(account) + else + web_url("@#{account.pretty_acct}") + end + + link_to(path || account_url, class: 'account__display-name') do content_tag(:div, class: 'account__avatar-wrapper') do image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46) end + diff --git a/app/helpers/invites_helper.rb b/app/helpers/invites_helper.rb deleted file mode 100644 index c189061db0bf0f..00000000000000 --- a/app/helpers/invites_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module InvitesHelper - def invites_max_uses_options - [1, 5, 10, 25, 50, 100] - end - - def invites_expires_options - [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week] - end -end diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 675d8b87309455..03809a166c2756 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -3,6 +3,8 @@ module JsonLdHelper include ContextHelper + UNSUPPORTED_JSONLD_KEYWORDS = %w(@graph @included @reverse).freeze + def equals_or_includes?(haystack, needle) haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle end @@ -70,6 +72,10 @@ def supported_context?(json) !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end + def supported_security_context?(json) + !json.nil? && equals_or_includes?(json['@context'], 'https://w3id.org/security/v1') + end + def unsupported_uri_scheme?(uri) uri.nil? || !uri.start_with?('http://', 'https://') end @@ -106,6 +112,16 @@ def compact(json) compacted end + def unsupported_jsonld_features?(json) + if json.is_a?(Hash) + json.any? { |key, value| UNSUPPORTED_JSONLD_KEYWORDS.include?(key) || unsupported_jsonld_features?(value) } + elsif json.is_a?(Array) + json.any? { |value| unsupported_jsonld_features?(value) } + else + false + end + end + # Patches a JSON-LD document to avoid compatibility issues on redistribution # # Since compacting a JSON-LD document against Mastodon's built-in vocabulary @@ -226,6 +242,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' @@ -239,12 +321,12 @@ def valid_activitypub_content_type?(response) end def body_to_json(body, compare_id: nil) - json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body + json = body.is_a?(String) ? JSON.parse(body) : body return if compare_id.present? && json['id'] != compare_id json - rescue Oj::ParseError + rescue JSON::ParserError nil end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index ddb6b79c8667b2..892249aab9bab4 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -35,7 +35,7 @@ module LanguagesHelper cy: ['Welsh', 'Cymraeg'].freeze, da: ['Danish', 'dansk'].freeze, de: ['German', 'Deutsch'].freeze, - dv: ['Divehi', 'Dhivehi'].freeze, + dv: ['Divehi', 'ދިވެހި'].freeze, dz: ['Dzongkha', 'རྫོང་ཁ'].freeze, ee: ['Ewe', 'Eʋegbe'].freeze, el: ['Greek', 'Ελληνικά'].freeze, @@ -100,7 +100,7 @@ module LanguagesHelper lo: ['Lao', 'ລາວ'].freeze, lt: ['Lithuanian', 'lietuvių kalba'].freeze, lu: ['Luba-Katanga', 'Tshiluba'].freeze, - lv: ['Latvian', 'latviešu valoda'].freeze, + lv: ['Latvian', 'Latviski'].freeze, mg: ['Malagasy', 'fiteny malagasy'].freeze, mh: ['Marshallese', 'Kajin M̧ajeļ'].freeze, mi: ['Māori', 'te reo Māori'].freeze, @@ -199,8 +199,10 @@ module LanguagesHelper kab: ['Kabyle', 'Taqbaylit'].freeze, ldn: ['Láadan', 'Láadan'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, + lzz: ['Lazuri', 'ლაზური ნენა'].freeze, moh: ['Mohawk', 'Kanienʼkéha'].freeze, nds: ['Low German', 'Plattdüütsch'].freeze, + ota: ['Ottoman Turkish', 'لسان عثمانی'].freeze, pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze, sco: ['Scots', 'Scots'].freeze, sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze, @@ -209,6 +211,7 @@ module LanguagesHelper tok: ['Toki Pona', 'toki pona'].freeze, vai: ['Vai', 'ꕙꔤ'].freeze, xal: ['Kalmyk', 'Хальмг келн'].freeze, + xmf: ['Mingrelian', 'მარგალური ნინა'].freeze, zba: ['Balaibalan', 'باليبلن'].freeze, zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, }.freeze @@ -223,7 +226,14 @@ module LanguagesHelper 'zh-YUE': ['Cantonese', '廣東話'].freeze, }.freeze - SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).freeze + # Since nan is not translated but nan-TW is translated, + # to enable the ISO-639-3 language-code with the regional variant but no + # official name, we use a specific hash for nan-TW + ISO_639_3_REGIONAL = { + 'nan-TW': ['Hokkien (Taiwan)', '臺語 (Hô-ló話)'].freeze, + }.freeze + + SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).merge(ISO_639_3_REGIONAL).freeze # For ISO-639-1 and ISO-639-3 language codes, we have their official # names, but for some translations, we need the names of the diff --git a/app/helpers/media_player_helper.rb b/app/helpers/media_player_helper.rb new file mode 100644 index 00000000000000..8b617164d75645 --- /dev/null +++ b/app/helpers/media_player_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module MediaPlayerHelper + PLAYER_HEIGHT = 380 + PLAYER_WIDTH = 670 +end diff --git a/app/helpers/react_component_helper.rb b/app/helpers/react_component_helper.rb index 821a6f1e2d451d..a9a7a8d7c9db56 100644 --- a/app/helpers/react_component_helper.rb +++ b/app/helpers/react_component_helper.rb @@ -2,33 +2,16 @@ module ReactComponentHelper def react_component(name, props = {}, &block) - data = { component: name.to_s.camelcase, props: Oj.dump(props) } - if block.nil? - div_tag_with_data(data) + data = { component: name.to_s.camelcase, props: } + if block_given? + tag.div data:, &block else - content_tag(:div, data: data, &block) + tag.div nil, data: end end def react_admin_component(name, props = {}) - data = { 'admin-component': name.to_s.camelcase, props: Oj.dump(props) } - div_tag_with_data(data) - end - - def serialized_media_attachments(media_attachments) - media_attachments.map { |attachment| serialized_attachment(attachment) } - end - - private - - def div_tag_with_data(data) - content_tag(:div, nil, data: data) - end - - def serialized_attachment(attachment) - ActiveModelSerializers::SerializableResource.new( - attachment, - serializer: REST::MediaAttachmentSerializer - ).as_json + data = { 'admin-component': name.to_s.camelcase, props: } + tag.div nil, data: end end diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index 002d167c05833a..fd3979f5af23e7 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -18,4 +18,12 @@ def omniauth_only? def ip_blocked?(remote_ip) IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? end + + def terms_agreement_label + if TermsOfService.live.exists? + t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path) + else + t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path) + end + end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fd631ce92ecd30..b99e0023f411b6 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -23,6 +23,20 @@ def featured_tags_hint(recently_used_tags) ) end + def user_settings_collection(value) + UserSettings.definition_for(value)&.in || [] + end + + def author_attribution_name(account) + return if account.nil? + + link_to(root_url, class: 'story__details__shared__author-link') do + safe_join( + [image_tag(account.avatar.url, class: 'account__avatar', size: 16, alt: ''), tag.bdi(display_name(account))] + ) + end + end + def session_device_icon(session) device = session.detection.device @@ -43,6 +57,20 @@ def compact_account_link_to(account) end end + def default_content_type_label(content_type) + variant = content_type.split('/')[1] + safe_join( + [ + t("simple_form.labels.defaults.setting_default_content_type_#{variant}"), + content_tag(:span, t("simple_form.hints.defaults.setting_default_content_type_#{variant}"), class: 'hint'), + ] + ) + end + + def time_zone_options + ActiveSupport::TimeZone.all.map { |tz| ["(GMT#{tz.now.formatted_offset}) #{tz.name}", tz.tzinfo.name] } + end + private def links_for_featured_tags(tags) diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 9cf64d09b4d0ad..84dea96faf3559 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -46,6 +46,14 @@ def poll_summary(status) status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n") end + def status_classnames(status, is_quote) + if is_quote + 'status--is-quote' + elsif status.quote.present? + 'status--has-quote' + end + end + def status_description(status) components = [[media_summary(status), status_text_summary(status)].compact_blank.join(' · ')] @@ -57,6 +65,20 @@ def status_description(status) components.compact_blank.join("\n\n") end + # This logic should be kept in sync with https://github.com/mastodon/mastodon/blob/425311e1d95c8a64ddac6c724fca247b8b893a82/app/javascript/mastodon/features/status/components/card.jsx#L160 + def preview_card_aspect_ratio_classname(preview_card) + interactive = preview_card.type == 'video' + large_image = (preview_card.image.present? && preview_card.width > preview_card.height) || interactive + + if large_image && interactive + 'status-card__image--video' + elsif large_image + 'status-card__image--large' + else + 'status-card__image--normal' + end + end + def visibility_icon(status) VISIBLITY_ICONS[status.visibility.to_sym] end diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index a2bef6b33cebbb..a434ef40ffc8e8 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -1,29 +1,39 @@ # frozen_string_literal: true module ThemeHelper - def theme_style_tags(flavour_and_skin) - flavour, theme = flavour_and_skin + def javascript_inline_tag(path) + entry = InlineScriptManager.instance.file(path) - if theme == 'system' - ''.html_safe.tap do |tags| - tags << vite_stylesheet_tag("skins/#{flavour}/mastodon-light", type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') - tags << vite_stylesheet_tag("skins/#{flavour}/default", type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + # 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 "skins/#{flavour}/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' end + + content_tag(:script, entry[:contents], type: 'text/javascript') end - def theme_color_tags(flavour_and_skin) - _, theme = flavour_and_skin + def theme_style_tags(flavour_and_skin) + flavour, theme = flavour_and_skin - if theme == 'system' + vite_stylesheet_tag "skins/#{flavour}/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' + end + + 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 @@ -38,6 +48,33 @@ def custom_stylesheet ) end + def current_flavour + [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) } + end + + def current_skin + skins = Themes.instance.skins_for(current_flavour) + [current_user&.setting_skin, Setting.skin, 'default'].find { |skin| skins.include?(skin) } + end + + def current_theme + # NOTE: this is slightly different from upstream, as it's a derived value used + # for the sole purpose of pointing to the appropriate stylesheet pack + [current_flavour, current_skin] + end + + def color_scheme + current_user&.setting_color_scheme || 'auto' + end + + def contrast + current_user&.setting_contrast || 'auto' + end + + def page_color_scheme + content_for(:force_color_scheme).presence || color_scheme + end + private def active_custom_stylesheet @@ -53,8 +90,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 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 131234b02ebe4c..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-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze, - { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze, - { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.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) || '').split('.') - - safe_join(Array.new(3) 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}_1i" + "#{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/config/html-tags.json b/app/javascript/config/html-tags.json new file mode 100644 index 00000000000000..cf5c96540a5e72 --- /dev/null +++ b/app/javascript/config/html-tags.json @@ -0,0 +1,78 @@ +{ + "global": { + "class": "className", + "id": true, + "title": true, + "dir": true, + "lang": true + }, + "tags": { + "p": {}, + "br": { + "children": false + }, + "span": { + "attributes": { + "translate": true + } + }, + "a": { + "attributes": { + "href": true, + "rel": true, + "translate": true, + "target": true, + "title": true + } + }, + "abbr": { + "attributes": { + "title": true + } + }, + "del": {}, + "s": {}, + "pre": {}, + "blockquote": { + "attributes": { + "cite": true + } + }, + "code": {}, + "b": {}, + "strong": {}, + "u": {}, + "sub": {}, + "sup": {}, + "i": {}, + "img": { + "children": false, + "attributes": { + "src": true, + "alt": true, + "title": true + } + }, + "em": {}, + "h1": {}, + "h2": {}, + "h3": {}, + "h4": {}, + "h5": {}, + "ul": {}, + "ol": { + "attributes": { + "start": true, + "reversed": true + } + }, + "li": { + "attributes": { + "value": true + } + }, + "ruby": {}, + "rt": {}, + "rp": {} + } +} diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index a60778f0c045f7..49ab58cc5bc912 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -1,6 +1,7 @@ import { createRoot } from 'react-dom/client'; -import Rails from '@rails/ujs'; +import { decode, ValidationError } from 'blurhash'; +import { on } from 'delegated-events'; import ready from '../mastodon/ready'; @@ -23,10 +24,9 @@ const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { } }; -Rails.delegate( - document, - 'input[type="datetime-local"]#announcement_starts_at', +on( 'change', + 'input[type="datetime-local"]#announcement_starts_at', ({ target }) => { if (target instanceof HTMLInputElement) setAnnouncementEndsAttributes(target); @@ -62,15 +62,16 @@ const hideSelectAll = () => { if (hiddenField) hiddenField.value = '0'; }; -Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { +on('change', '#batch_checkbox_all', ({ target }) => { if (!(target instanceof HTMLInputElement)) return; const selectAllMatchingElement = document.querySelector( '.batch-table__select-all', ); - document - .querySelectorAll(batchCheckboxClassName) + target + .closest('.batch-table') + ?.querySelectorAll(batchCheckboxClassName) .forEach((content) => { content.checked = target.checked; }); @@ -84,7 +85,7 @@ Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { } }); -Rails.delegate(document, '.batch-table__select-all button', 'click', () => { +on('click', '.batch-table__select-all button', () => { const hiddenField = document.querySelector( '#select_all_matching', ); @@ -112,17 +113,20 @@ Rails.delegate(document, '.batch-table__select-all button', 'click', () => { } }); -Rails.delegate(document, batchCheckboxClassName, 'change', () => { - const checkAllElement = document.querySelector( +on('change', batchCheckboxClassName, (event) => { + const targetTable = (event.target as HTMLElement).closest('.batch-table'); + if (!targetTable) return; + + const checkAllElement = targetTable.querySelector( 'input#batch_checkbox_all', ); - const selectAllMatchingElement = document.querySelector( + const selectAllMatchingElement = targetTable.querySelector( '.batch-table__select-all', ); if (checkAllElement) { const allCheckboxes = Array.from( - document.querySelectorAll(batchCheckboxClassName), + targetTable.querySelectorAll(batchCheckboxClassName), ); checkAllElement.checked = allCheckboxes.every((content) => content.checked); checkAllElement.indeterminate = @@ -139,14 +143,9 @@ Rails.delegate(document, batchCheckboxClassName, 'change', () => { } }); -Rails.delegate( - document, - '.filter-subset--with-select select', - 'change', - ({ target }) => { - if (target instanceof HTMLSelectElement) target.form?.submit(); - }, -); +on('change', '.filter-subset--with-select select', ({ target }) => { + if (target instanceof HTMLSelectElement) target.form?.submit(); +}); const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { const rejectMediaDiv = document.querySelector( @@ -167,11 +166,43 @@ const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { } }; -Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { +on('change', '#domain_block_severity', ({ target }) => { if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); }); -const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { +const onChangeInviteUsersPermission = (target: HTMLInputElement) => { + const inviteBypassApprovalCheckbox = document.querySelector( + 'input#user_role_permissions_as_keys_invite_bypass_approval', + ); + + if (inviteBypassApprovalCheckbox) { + inviteBypassApprovalCheckbox.disabled = !target.checked; + + if (target.checked) { + inviteBypassApprovalCheckbox.parentElement?.classList.remove('disabled'); + inviteBypassApprovalCheckbox.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + inviteBypassApprovalCheckbox.parentElement?.classList.add('disabled'); + inviteBypassApprovalCheckbox.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +on( + 'change', + 'input#user_role_permissions_as_keys_invite_users', + ({ target }) => { + if (target instanceof HTMLInputElement) { + onChangeInviteUsersPermission(target); + } + }, +); + +function onEnableBootstrapTimelineAccountsChange(target: HTMLInputElement) { const bootstrapTimelineAccountsField = document.querySelector( '#form_admin_settings_bootstrap_timeline_accounts', @@ -193,12 +224,11 @@ const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { ); } } -}; +} -Rails.delegate( - document, - '#form_admin_settings_enable_bootstrap_timeline_accounts', +on( 'change', + '#form_admin_settings_enable_bootstrap_timeline_accounts', ({ target }) => { if (target instanceof HTMLInputElement) onEnableBootstrapTimelineAccountsChange(target); @@ -238,11 +268,11 @@ const onChangeRegistrationMode = (target: HTMLSelectElement) => { }); }; -const convertUTCDateTimeToLocal = (value: string) => { +function convertUTCDateTimeToLocal(value: string) { const date = new Date(value + 'Z'); const twoChars = (x: number) => x.toString().padStart(2, '0'); return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; -}; +} function convertLocalDatetimeToUTC(value: string) { const date = new Date(value); @@ -250,14 +280,9 @@ function convertLocalDatetimeToUTC(value: string) { return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); } -Rails.delegate( - document, - '#form_admin_settings_registrations_mode', - 'change', - ({ target }) => { - if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); - }, -); +on('change', '#form_admin_settings_registrations_mode', ({ target }) => { + if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); +}); async function mountReactComponent(element: Element) { const componentName = element.getAttribute('data-admin-component'); @@ -267,9 +292,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` @@ -303,8 +327,15 @@ ready(() => { ); if (registrationMode) onChangeRegistrationMode(registrationMode); + const inviteUsersPermissionChecbkox = + document.querySelector( + 'input#user_role_permissions_as_keys_invite_users', + ); + if (inviteUsersPermissionChecbkox) + onChangeInviteUsersPermission(inviteUsersPermissionChecbkox); + const checkAllElement = document.querySelector( - 'input#batch_checkbox_all', + '#batch_checkbox_all', ); if (checkAllElement) { const allCheckboxes = Array.from( @@ -317,7 +348,7 @@ ready(() => { } document - .querySelector('a#add-instance-button') + .querySelector('a#add-instance-button') ?.addEventListener('click', (e) => { const domain = document.querySelector( 'input[type="text"]#by_domain', @@ -341,7 +372,7 @@ ready(() => { } }); - Rails.delegate(document, 'form', 'submit', ({ target }) => { + on('submit', 'form', ({ target }) => { if (target instanceof HTMLFormElement) target .querySelectorAll('input[type="datetime-local"]') @@ -362,6 +393,46 @@ ready(() => { document.querySelectorAll('[data-admin-component]').forEach((element) => { void mountReactComponent(element); }); + + document + .querySelectorAll('canvas[data-blurhash]') + .forEach((canvas) => { + const blurhash = canvas.dataset.blurhash; + if (blurhash) { + try { + // decode returns a Uint8ClampedArray not Uint8ClampedArray + const pixels = decode( + blurhash, + 32, + 32, + ) as Uint8ClampedArray; + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx?.putImageData(imageData, 0, 0); + } catch (err) { + if (err instanceof ValidationError) { + // ignore blurhash validation errors + return; + } + + throw err; + } + } + }); + + document + .querySelectorAll('.preview-card') + .forEach((previewCard) => { + const spoilerButton = previewCard.querySelector('.spoiler-button'); + if (!spoilerButton) { + return; + } + + spoilerButton.addEventListener('click', () => { + previewCard.classList.toggle('preview-card--image-visible'); + }); + }); }).catch((reason: unknown) => { throw reason; }); diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index dd1956446daeeb..5563b8324e68a9 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -1,19 +1,28 @@ import { createRoot } from 'react-dom/client'; import { IntlMessageFormat } from 'intl-messageformat'; -import type { MessageDescriptor, PrimitiveType } from 'react-intl'; +import type { + FormatDateOptions, + IntlShape, + MessageDescriptor, + PrimitiveType, +} from 'react-intl'; import { defineMessages } from 'react-intl'; -import Rails from '@rails/ujs'; import axios from 'axios'; +import { on } from 'delegated-events'; import { throttle } from 'lodash'; -import { timeAgoString } from '../mastodon/components/relative_timestamp'; -import emojify from '../mastodon/features/emoji/emoji'; -import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; -import { loadLocale, getLocale } from '../mastodon/locales'; -import { loadPolyfills } from '../mastodon/polyfills'; -import ready from '../mastodon/ready'; +import { determineEmojiMode } from '@/mastodon/features/emoji/mode'; +import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render'; +import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions'; +import { loadLocale, getLocale } from '@/mastodon/locales'; +import { loadPolyfills } from '@/mastodon/polyfills'; +import ready from '@/mastodon/ready'; +import { assetHost } from '@/mastodon/utils/config'; +import { getNestedProperty } from '@/mastodon/utils/objects'; +import { isDarkMode } from '@/mastodon/utils/theme'; +import { formatTime } from '@/mastodon/utils/time'; import 'cocoon-js-vanilla'; @@ -32,7 +41,7 @@ const messages = defineMessages({ }, }); -function loaded() { +async function loaded() { const { messages: localeData } = getLocale(); const locale = document.documentElement.lang; @@ -58,7 +67,7 @@ function loaded() { const formatMessage = ( { id, defaultMessage }: MessageDescriptor, values?: Record, - ) => { + ): string => { let message: string | undefined = undefined; if (id) message = localeData[id]; @@ -69,9 +78,30 @@ function loaded() { return messageFormat.format(values) as string; }; - document.querySelectorAll('.emojify').forEach((content) => { - content.innerHTML = emojify(content.innerHTML); - }); + let emojiStyle = 'auto'; + const initialStateText = + document.getElementById('initial-state')?.textContent; + if (initialStateText) { + const stateEmojiStyle = getNestedProperty( + JSON.parse(initialStateText) as unknown, + 'meta', + 'emoji_style', + ); + if (typeof stateEmojiStyle === 'string') { + emojiStyle = stateEmojiStyle; + } + } + const emojiMode = determineEmojiMode(emojiStyle); + const darkTheme = isDarkMode(); + for (const element of document.querySelectorAll('.emojify')) { + await updateHtmlWithEmoji({ + assetHost, + element, + locale, + mode: emojiMode, + darkTheme, + }); + } document .querySelectorAll('time.formatted') @@ -126,29 +156,33 @@ function loaded() { .querySelectorAll('time.time-ago') .forEach((content) => { const datetime = new Date(content.dateTime); - const now = new Date(); const timeGiven = content.dateTime.includes('T'); content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); - content.textContent = timeAgoString( - { - formatMessage, - formatDate: (date: Date, options) => + const now = Date.now(); + content.textContent = formatTime({ + // We don't want to show future dates. + timestamp: Math.min(datetime.getTime(), now), + now, + intl: { + formatMessage: formatMessage as IntlShape['formatMessage'], + formatDate: (date: Date, options: FormatDateOptions) => new Intl.DateTimeFormat(locale, options).format(date), }, - datetime, - now.getTime(), - now.getFullYear(), - timeGiven, - ); + noTime: !timeGiven, + }); }); updateDefaultQuotePrivacyFromPrivacy( document.querySelector('#user_settings_attributes_default_privacy'), ); + truncateRuleHints(); + + applyRailsA11yPatches(); + const reactComponents = document.querySelectorAll('[data-component]'); if (reactComponents.length > 0) { @@ -175,23 +209,32 @@ function loaded() { }); } - Rails.delegate( - document, - 'input#user_account_attributes_username', + on( 'input', + 'input#user_account_attributes_username', throttle( ({ target }) => { if (!(target instanceof HTMLInputElement)) return; - if (target.value && target.value.length > 0) { + const checkedUsername = target.value; + if (checkedUsername && checkedUsername.length > 0) { axios - .get('/api/v1/accounts/lookup', { params: { acct: target.value } }) + .get('/api/v1/accounts/lookup', { + params: { acct: checkedUsername }, + }) .then(() => { - target.setCustomValidity(formatMessage(messages.usernameTaken)); + // Only update the validity if the result is for the currently-typed username + if (checkedUsername === target.value) { + target.setCustomValidity(formatMessage(messages.usernameTaken)); + } + return true; }) .catch(() => { - target.setCustomValidity(''); + // Only update the validity if the result is for the currently-typed username + if (checkedUsername === target.value) { + target.setCustomValidity(''); + } }); } else { target.setCustomValidity(''); @@ -202,60 +245,47 @@ function loaded() { ), ); - Rails.delegate( - document, - '#user_password,#user_password_confirmation', - 'input', - () => { - const password = document.querySelector( - 'input#user_password', + on('input', '#user_password,#user_password_confirmation', () => { + const password = document.querySelector( + 'input#user_password', + ); + const confirmation = document.querySelector( + 'input#user_password_confirmation', + ); + if (!confirmation || !password) return; + + if (confirmation.value && confirmation.value.length > password.maxLength) { + confirmation.setCustomValidity( + formatMessage(messages.passwordExceedsLength), ); - const confirmation = document.querySelector( - 'input#user_password_confirmation', + } else if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity( + formatMessage(messages.passwordDoesNotMatch), ); - if (!confirmation || !password) return; - - if ( - confirmation.value && - confirmation.value.length > password.maxLength - ) { - confirmation.setCustomValidity( - formatMessage(messages.passwordExceedsLength), - ); - } else if (password.value && password.value !== confirmation.value) { - confirmation.setCustomValidity( - formatMessage(messages.passwordDoesNotMatch), - ); - } else { - confirmation.setCustomValidity(''); - } - }, - ); + } else { + confirmation.setCustomValidity(''); + } + }); } -Rails.delegate( - document, - '#edit_profile input[type=file]', - 'change', - ({ target }) => { - if (!(target instanceof HTMLInputElement)) return; +on('change', '#edit_profile input[type=file]', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; - const avatar = document.querySelector( - `img#${target.id}-preview`, - ); + const avatar = document.querySelector( + `img#${target.id}-preview`, + ); - if (!avatar) return; + if (!avatar) return; - let file: File | undefined; - if (target.files) file = target.files[0]; + let file: File | undefined; + if (target.files) file = target.files[0]; - const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; - if (url) avatar.src = url; - }, -); + if (url) avatar.src = url; +}); -Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { +on('click', '.input-copy input', ({ target }) => { if (!(target instanceof HTMLInputElement)) return; target.focus(); @@ -263,7 +293,7 @@ Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { target.setSelectionRange(0, target.value.length); }); -Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { +on('click', '.input-copy button', ({ target }) => { if (!(target instanceof HTMLButtonElement)) return; const input = target.parentNode?.querySelector( @@ -312,22 +342,22 @@ const toggleSidebar = () => { sidebar.classList.toggle('visible'); }; -Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => { +on('click', '.sidebar__toggle__icon', () => { toggleSidebar(); }); -Rails.delegate(document, '.sidebar__toggle__icon', 'keydown', (e) => { +on('keydown', '.sidebar__toggle__icon', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleSidebar(); } }); -Rails.delegate(document, 'img.custom-emoji', 'mouseover', ({ target }) => { +on('mouseover', 'img.custom-emoji', ({ target }) => { if (target instanceof HTMLImageElement && target.dataset.original) target.src = target.dataset.original; }); -Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => { +on('mouseout', 'img.custom-emoji', ({ target }) => { if (target instanceof HTMLImageElement && target.dataset.static) target.src = target.dataset.static; }); @@ -376,22 +406,17 @@ const setInputHint = ( } }; -Rails.delegate( - document, - '#account_statuses_cleanup_policy_enabled', - 'change', - ({ target }) => { - if (!(target instanceof HTMLInputElement) || !target.form) return; - - target.form - .querySelectorAll< - HTMLInputElement | HTMLSelectElement - >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select') - .forEach((input) => { - setInputDisabled(input, !target.checked); - }); - }, -); +on('change', '#account_statuses_cleanup_policy_enabled', ({ target }) => { + if (!(target instanceof HTMLInputElement) || !target.form) return; + + target.form + .querySelectorAll( + 'input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select', + ) + .forEach((input) => { + setInputDisabled(input, !target.checked); + }); +}); const updateDefaultQuotePrivacyFromPrivacy = ( privacySelect: EventTarget | null, @@ -414,18 +439,13 @@ const updateDefaultQuotePrivacyFromPrivacy = ( } }; -Rails.delegate( - document, - '#user_settings_attributes_default_privacy', - 'change', - ({ target }) => { - updateDefaultQuotePrivacyFromPrivacy(target); - }, -); +on('change', '#user_settings_attributes_default_privacy', ({ target }) => { + updateDefaultQuotePrivacyFromPrivacy(target); +}); // Empty the honeypot fields in JS in case something like an extension // automatically filled them. -Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { +on('submit', '#registration_new_user,#new_user', () => { [ 'user_website', 'user_confirm_password', @@ -439,24 +459,146 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { }); }); -Rails.delegate(document, '.rules-list button', 'click', ({ target }) => { - if (!(target instanceof HTMLElement)) { - return; - } +// Truncate long rule hints + +const MAX_RULE_HINT_LENGTH = 100; + +function truncateRuleHints() { + const ruleListItems = + document.querySelectorAll('.rules-list li'); + if (!ruleListItems.length) return; + + ruleListItems.forEach((item) => { + toggleRuleHint(item, true); + }); +} - const button = target.closest('button'); +function toggleRuleHint(listItem: HTMLLIElement, isInitialSetup?: boolean) { + const hint = listItem.querySelector( + '.rules-list__hint-text', + ); + if (!hint) return; + + const hintText = hint.innerHTML; + const hintToggleButton = listItem.querySelector('button'); + + if (hintText.length > MAX_RULE_HINT_LENGTH) { + // Store full hint in a data attribute, then truncate it with an '…' + hint.dataset.fullHint = hintText; + hint.innerHTML = `${hintText.slice(0, MAX_RULE_HINT_LENGTH - 1).trim()}…`; - if (!button) { + if (hintToggleButton) { + // Reveal toggle button if needed + hintToggleButton.removeAttribute('hidden'); + hintToggleButton.setAttribute('aria-expanded', 'false'); + } + } else if (!isInitialSetup) { + const { fullHint } = hint.dataset; + if (fullHint) { + // Restore full hint from data attribute, then delete attribute + hint.innerHTML = fullHint; + delete hint.dataset.fullHint; + + hintToggleButton?.setAttribute('aria-expanded', 'true'); + hint.parentElement?.focus(); + } + } +} + +on('click', '.rules-list button', ({ target }) => { + if (!(target instanceof HTMLElement)) { return; } - if (button.ariaExpanded === 'true') { - button.ariaExpanded = 'false'; - } else { - button.ariaExpanded = 'true'; + const listItem = target.closest('li'); + + if (listItem) { + toggleRuleHint(listItem); } }); +/** + * Patch accessibility issues caused by Ruby Gems that + * don't produce accessible markup (simple-forms & simple-navigation) + */ +function applyRailsA11yPatches() { + /** + * Mark current navigation item with aria-current + */ + const activeNavLink = document.querySelector( + '.simple-navigation-active-leaf a.selected', + ); + activeNavLink?.setAttribute('aria-current', 'page'); + + /** + * Hides the asterisk added to labels of required form fields + * from assistive tech. (Those fields already have the `required` attribute) + */ + document + .querySelectorAll('.simple_form label.required abbr') + .forEach((element) => { + element.setAttribute('aria-hidden', 'true'); + }); + + /** + * Associate form field hints with their inputs via aria-describedby + */ + document + .querySelectorAll('.simple_form .field_with_hint') + .forEach((field) => { + const inputs = field.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement + >("input[type='text'], input[type='checkbox'], textarea"); + + const hint = field.querySelector('.hint'); + + // Bail out if there are more than one input as + // the association can't be safely made. + if (inputs.length !== 1 || !inputs[0] || !hint) { + return; + } + + const input = inputs[0]; + const inputId = input.getAttribute('id'); + const hintId = `${inputId}_hint`; + + input.setAttribute('aria-describedby', hintId); + hint.setAttribute('id', hintId); + }); + + /** + * Add fieldset-like group labels ("legends") to the date-of-birth selector + * and groups of radio buttons + */ + const groups = document.querySelectorAll( + '.simple_form .date_of_birth, .simple_form .input.with_label.radio_buttons', + ); + groups.forEach((groupWrapper) => { + // This is the element serving as the label of the group. + const groupLabel = groupWrapper.querySelector('label'); + const labelWithId = + groupWrapper.querySelector('label[for]'); + const groupHint = groupWrapper.querySelector('.hint'); + + // We need a unique ID to generate the aria associations. If `groupLabel` + // doesn't have one, we just take the first label with a `for` attribute + // that we can find, which is fine because we'll modify it before use. + const inputId = + groupLabel?.getAttribute('for') ?? labelWithId?.getAttribute('for'); + const labelId = `${inputId}_label`; + const hintId = `${inputId}_hint`; + + groupLabel?.setAttribute('id', labelId); + groupHint?.setAttribute('id', hintId); + + groupWrapper.setAttribute('role', 'group'); + groupWrapper.setAttribute('aria-labelledby', labelId); + if (groupHint) { + groupWrapper.setAttribute('aria-describedby', hintId); + } + }); +} + function main() { ready(loaded).catch((error: unknown) => { console.error(error); diff --git a/app/javascript/entrypoints/remote_interaction_helper.ts b/app/javascript/entrypoints/remote_interaction_helper.ts index f50203747d8f28..093f6a7ec29147 100644 --- a/app/javascript/entrypoints/remote_interaction_helper.ts +++ b/app/javascript/entrypoints/remote_interaction_helper.ts @@ -39,18 +39,65 @@ const findLink = (rel: string, data: unknown): JRDLink | undefined => { } }; -const findTemplateLink = (data: unknown) => - findLink('http://ostatus.org/schema/1.0/subscribe', data)?.template; +const intentParams = (intent: string): [string, string] | null => { + switch (intent) { + case 'follow': + return ['https://w3id.org/fep/3b86/Follow', 'object']; + case 'reblog': + return ['https://w3id.org/fep/3b86/Announce', 'object']; + case 'favourite': + return ['https://w3id.org/fep/3b86/Like', 'object']; + case 'vote': + case 'reply': + return ['https://w3id.org/fep/3b86/Object', 'object']; + default: + return null; + } +}; + +const findTemplateLink = ( + data: unknown, + intent: string, +): [string, string] | [null, null] => { + // Find the FEP-3b86 handler for the specific intent + const [needle, param] = intentParams(intent) ?? [ + 'http://ostatus.org/schema/1.0/subscribe', + 'uri', + ]; + + const match = findLink(needle, data); + + if (match?.template) { + return [match.template, param]; + } + + // If the specific intent wasn't found, try the FEP-3b86 handler for the `Object` intent + let fallback = findLink('https://w3id.org/fep/3b86/Object', data); + if (fallback?.template) { + return [fallback.template, 'object']; + } + + // If it's still not found, try the legacy OStatus subscribe handler + fallback = findLink('http://ostatus.org/schema/1.0/subscribe', data); + + if (fallback?.template) { + return [fallback.template, 'uri']; + } + + return [null, null]; +}; const fetchInteractionURLSuccess = ( uri_or_domain: string, template: string, + param: string, ) => { window.parent.postMessage( { type: 'fetchInteractionURL-success', uri_or_domain, template, + param, }, window.origin, ); @@ -74,7 +121,7 @@ const isValidDomain = (value: unknown) => { }; // Attempt to find a remote interaction URL from a domain -const fromDomain = (domain: string) => { +const fromDomain = (domain: string, intent: string) => { const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; axios @@ -82,17 +129,21 @@ const fromDomain = (domain: string) => { params: { resource: `https://${domain}` }, }) .then(({ data }) => { - const template = findTemplateLink(data); - fetchInteractionURLSuccess(domain, template ?? fallbackTemplate); + const [template, param] = findTemplateLink(data, intent); + fetchInteractionURLSuccess( + domain, + template ?? fallbackTemplate, + param ?? 'uri', + ); return; }) .catch(() => { - fetchInteractionURLSuccess(domain, fallbackTemplate); + fetchInteractionURLSuccess(domain, fallbackTemplate, 'uri'); }); }; // Attempt to find a remote interaction URL from an arbitrary URL -const fromURL = (url: string) => { +const fromURL = (url: string, intent: string) => { const domain = new URL(url).host; const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; @@ -101,17 +152,21 @@ const fromURL = (url: string) => { params: { resource: url }, }) .then(({ data }) => { - const template = findTemplateLink(data); - fetchInteractionURLSuccess(url, template ?? fallbackTemplate); + const [template, param] = findTemplateLink(data, intent); + fetchInteractionURLSuccess( + url, + template ?? fallbackTemplate, + param ?? 'uri', + ); return; }) .catch(() => { - fromDomain(domain); + fromDomain(domain, intent); }); }; // Attempt to find a remote interaction URL from a `user@domain` string -const fromAcct = (acct: string) => { +const fromAcct = (acct: string, intent: string) => { acct = acct.replace(/^@/, ''); const segments = acct.split('@'); @@ -134,25 +189,29 @@ const fromAcct = (acct: string) => { params: { resource: `acct:${acct}` }, }) .then(({ data }) => { - const template = findTemplateLink(data); - fetchInteractionURLSuccess(acct, template ?? fallbackTemplate); + const [template, param] = findTemplateLink(data, intent); + fetchInteractionURLSuccess( + acct, + template ?? fallbackTemplate, + param ?? 'uri', + ); return; }) .catch(() => { // TODO: handle host-meta? - fromDomain(domain); + fromDomain(domain, intent); }); }; -const fetchInteractionURL = (uri_or_domain: string) => { +const fetchInteractionURL = (uri_or_domain: string, intent: string) => { if (uri_or_domain === '') { fetchInteractionURLFailure(); } else if (/^https?:\/\//.test(uri_or_domain)) { - fromURL(uri_or_domain); + fromURL(uri_or_domain, intent); } else if (uri_or_domain.includes('@')) { - fromAcct(uri_or_domain); + fromAcct(uri_or_domain, intent); } else { - fromDomain(uri_or_domain); + fromDomain(uri_or_domain, intent); } }; @@ -172,8 +231,10 @@ window.addEventListener('message', (event: MessageEvent) => { 'type' in event.data && event.data.type === 'fetchInteractionURL' && 'uri_or_domain' in event.data && - typeof event.data.uri_or_domain === 'string' + typeof event.data.uri_or_domain === 'string' && + 'intent' in event.data && + typeof event.data.intent === 'string' ) { - fetchInteractionURL(event.data.uri_or_domain); + fetchInteractionURL(event.data.uri_or_domain, event.data.intent); } }); 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..814703f8650bd3 --- /dev/null +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -0,0 +1,70 @@ +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 { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target'; +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/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index bf9f813ef023a7..07f6683b439fff 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/accounts_typed.ts b/app/javascript/flavours/glitch/actions/accounts_typed.ts index e49c3043fb2cb0..cb9e89d0487ec4 100644 --- a/app/javascript/flavours/glitch/actions/accounts_typed.ts +++ b/app/javascript/flavours/glitch/actions/accounts_typed.ts @@ -3,6 +3,7 @@ import { createAction } from '@reduxjs/toolkit'; import { apiRemoveAccountFromFollowers, apiGetEndorsedAccounts, + apiGetAccounts, } from 'flavours/glitch/api/accounts'; import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; @@ -113,3 +114,12 @@ export const fetchEndorsedAccounts = createDataLoadingThunk( return data; }, ); + +export const fetchAccounts = createDataLoadingThunk( + 'accounts/multi_accounts', + ({ accountIds }: { accountIds: string[] }) => apiGetAccounts(accountIds), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data)); + return data; + }, +); diff --git a/app/javascript/flavours/glitch/actions/bundles.js b/app/javascript/flavours/glitch/actions/bundles.js deleted file mode 100644 index ecc9c8f7d3ec22..00000000000000 --- a/app/javascript/flavours/glitch/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/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 387982325c3e75..8f66e5c66815bd 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -5,8 +5,9 @@ import { throttle } from 'lodash'; import api from 'flavours/glitch/api'; import { browserHistory } from 'flavours/glitch/components/router'; -import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light'; +import { countableText } from 'flavours/glitch/features/compose/util/counter'; import { tagHistory } from 'flavours/glitch/settings'; +import { emojiMartSearch } from '@/flavours/glitch/features/emoji/picker'; import { recoverHashtags } from 'flavours/glitch/utils/hashtag'; import { showAlert, showAlertForError } from './alerts'; @@ -57,17 +58,12 @@ export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE' export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; -export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; -export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; -export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; -export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; - export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET'; export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; @@ -93,17 +89,18 @@ const messages = defineMessages({ open: { id: 'compose.published.open', defaultMessage: 'Open' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, + blankPostError: { id: 'compose.error.blank_post', defaultMessage: 'Post can\'t be blank.' }, }); export const ensureComposeIsVisible = (getState) => { if (!getState().getIn(['compose', 'mounted'])) { - browserHistory.push('/publish'); + browserHistory.push('/publish', { focusTarget: false }); } }; export function setComposeToStatus(status, text, spoiler_text, content_type) { return (dispatch, getState) => { - const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']); + const maxOptions = getState().server.server.item?.configuration.polls.max_options; dispatch({ type: COMPOSE_SET_STATUS, @@ -160,10 +157,11 @@ export function resetCompose() { }; } -export const focusCompose = (defaultText = '') => (dispatch, getState) => { +export const focusCompose = (defaultText = '', caretStart = false) => (dispatch, getState) => { dispatch({ type: COMPOSE_FOCUS, defaultText, + caretStart, }); ensureComposeIsVisible(getState); @@ -197,24 +195,34 @@ export function directCompose(account) { }; } +/** + * @callback ComposeSuccessCallback + * @param {Object} status + */ + /** * @param {null | string} overridePrivacy - * @param {undefined | Function} successCallback + * @param {undefined | ComposeSuccessCallback} successCallback */ export function submitCompose(overridePrivacy = null, successCallback = undefined) { return function (dispatch, getState) { let status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); const statusId = getState().getIn(['compose', 'id'], null); + const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']); const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']); - let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : ''; + const spoiler_text = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : ''; - if ((!status || !status.length) && media.size === 0) { - return; - } + const fulltext = `${spoiler_text ?? ''}${countableText(status ?? '')}`; + const hasText = fulltext.trim().length > 0; - if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { - status = status + ' 👁️'; + if (!(hasText || media.size !== 0 || (hasQuote && spoiler_text?.length))) { + dispatch(showAlert({ + message: messages.blankPostError, + })); + dispatch(focusCompose()); + + return; } dispatch(submitComposeRequest()); @@ -245,12 +253,13 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine method: statusId === null ? 'post' : 'put', data: { status, + spoiler_text, content_type: getState().getIn(['compose', 'content_type']), + local_only: getState().getIn(['compose', 'advanced_options', 'do_not_federate']), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), media_attributes, - sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), - spoiler_text: spoilerText, + sensitive: getState().getIn(['compose', 'sensitive']) || (spoiler_text.length > 0 && media.size !== 0), visibility: visibility, poll: getState().getIn(['compose', 'poll'], null), language: getState().getIn(['compose', 'language']), @@ -305,7 +314,10 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine message: statusId === null ? messages.published : messages.saved, action: messages.open, dismissAfter: 10000, - onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`), + onClick: () => browserHistory.push( + `/@${response.data.account.username}/${response.data.id}`, + { focusTarget: 'detailed-status' } + ), })); } }).catch(function (error) { @@ -348,7 +360,7 @@ export function uploadCompose(files) { dispatch(showAlert({ message: messages.uploadQuote })); return; } - const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']); + const uploadLimit = getState().getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_media_attachments']); const media = getState().getIn(['compose', 'media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); @@ -360,11 +372,6 @@ export function uploadCompose(files) { return; } - if (getState().getIn(['compose', 'poll'])) { - dispatch(showAlert({ message: messages.uploadErrorPoll })); - return; - } - dispatch(uploadComposeRequest()); for (const [i, file] of Array.from(files).entries()) { @@ -484,58 +491,6 @@ export function onChangeMediaFocus(focusX, focusY) { }; } -export function changeUploadCompose(id, params) { - return (dispatch, getState) => { - dispatch(changeUploadComposeRequest()); - - let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id); - - // Editing already-attached media is deferred to editing the post itself. - // For simplicity's sake, fake an API reply. - if (media && !media.get('unattached')) { - const { focus, ...other } = params; - const data = { ...media.toJS(), ...other }; - - if (focus) { - const [x, y] = focus.split(','); - data.meta = { focus: { x: parseFloat(x), y: parseFloat(y) } }; - } - - dispatch(changeUploadComposeSuccess(data, true)); - } else { - api().put(`/api/v1/media/${id}`, params).then(response => { - dispatch(changeUploadComposeSuccess(response.data, false)); - }).catch(error => { - dispatch(changeUploadComposeFail(id, error)); - }); - } - }; -} - -export function changeUploadComposeRequest() { - return { - type: COMPOSE_UPLOAD_CHANGE_REQUEST, - skipLoading: true, - }; -} - -export function changeUploadComposeSuccess(media, attached) { - return { - type: COMPOSE_UPLOAD_CHANGE_SUCCESS, - media: media, - attached: attached, - skipLoading: true, - }; -} - -export function changeUploadComposeFail(error) { - return { - type: COMPOSE_UPLOAD_CHANGE_FAIL, - error: error, - skipLoading: true, - }; -} - export function uploadComposeRequest() { return { type: COMPOSE_UPLOAD_REQUEST, @@ -584,7 +539,7 @@ export function clearComposeSuggestions() { }; } -const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { +const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => { if (fetchComposeSuggestionsAccountsController) { fetchComposeSuggestionsAccountsController.abort(); } @@ -611,12 +566,14 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => }); }, 200, { leading: true, trailing: true }); -const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { - const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); +const fetchComposeSuggestionsEmojis = async (dispatch, token) => { + // Right now we are hard-coding the locale to English since the picker search only supports English. + // Once we replace the legacy picker we can remove this and use the actual locale of the user. + const results = await emojiMartSearch(token, 'en', 5); dispatch(readyComposeSuggestionsEmojis(token, results)); }; -const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { +const fetchComposeSuggestionsTags = throttle((dispatch, token) => { if (fetchComposeSuggestionsTagsController) { fetchComposeSuggestionsTagsController.abort(); } @@ -649,13 +606,14 @@ export function fetchComposeSuggestions(token) { return (dispatch, getState) => { switch (token[0]) { case ':': - fetchComposeSuggestionsEmojis(dispatch, getState, token); + void fetchComposeSuggestionsEmojis(dispatch, token); break; case '#': - fetchComposeSuggestionsTags(dispatch, getState, token); + case '#': + fetchComposeSuggestionsTags(dispatch, token); break; default: - fetchComposeSuggestionsAccounts(dispatch, getState, token); + fetchComposeSuggestionsAccounts(dispatch, token); break; } }; @@ -688,16 +646,25 @@ export function selectComposeSuggestion(position, token, suggestion, path) { let completion, startPosition; if (suggestion.type === 'emoji') { - completion = suggestion.native || suggestion.colons; + completion = suggestion.native || `:${suggestion.id}:`; startPosition = position - 1; dispatch(useEmoji(suggestion)); } else if (suggestion.type === 'hashtag') { - completion = `#${suggestion.name}`; + // 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']); - startPosition = position; + completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`; + startPosition = position - 1; } // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that @@ -808,13 +775,6 @@ export function changeComposeSpoilerText(text) { }; } -export function changeComposeVisibility(value) { - return { - type: COMPOSE_VISIBILITY_CHANGE, - value, - }; -} - export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/flavours/glitch/actions/compose_typed.ts b/app/javascript/flavours/glitch/actions/compose_typed.ts index f0219f7da7949b..d89157e534ebe4 100644 --- a/app/javascript/flavours/glitch/actions/compose_typed.ts +++ b/app/javascript/flavours/glitch/actions/compose_typed.ts @@ -4,6 +4,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { apiUpdateMedia } from 'flavours/glitch/api/compose'; +import { apiGetSearch } from 'flavours/glitch/api/search'; import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments'; import type { MediaAttachment } from 'flavours/glitch/models/media_attachment'; import { @@ -12,13 +13,19 @@ import { } from 'flavours/glitch/store/typed_functions'; import type { ApiQuotePolicy } from '../api_types/quotes'; -import type { Status } from '../models/status'; +import type { Status, StatusVisibility } from '../models/status'; +import type { RootState } from '../store'; import { showAlert } from './alerts'; -import { focusCompose } from './compose'; +import { changeCompose, focusCompose } from './compose'; +import { importFetchedStatuses } from './importer'; import { openModal } from './modal'; const messages = defineMessages({ + quoteErrorEdit: { + id: 'quote_error.edit', + defaultMessage: 'Quotes cannot be added when editing a post.', + }, quoteErrorUpload: { id: 'quote_error.upload', defaultMessage: 'Quoting is not allowed with media attachments.', @@ -35,10 +42,14 @@ const messages = defineMessages({ id: 'quote_error.unauthorized', defaultMessage: 'You are not authorized to quote this post.', }, + quoteErrorPrivateMention: { + id: 'quote_error.private_mentions', + defaultMessage: 'Quoting is not allowed with direct mentions.', + }, }); type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { - unattached?: boolean; + attached?: boolean; }; const simulateModifiedApiResponse = ( @@ -56,11 +67,45 @@ const simulateModifiedApiResponse = ( y: parseFloat(y ?? '0'), }, }, - } as unknown as SimulatedMediaAttachmentJSON; + attached: true, + } as SimulatedMediaAttachmentJSON; return data; }; +export const changeComposeVisibility = createAppThunk( + 'compose/visibility_change', + (visibility: StatusVisibility, { dispatch, getState }) => { + if (visibility !== 'direct') { + return visibility; + } + + const state = getState(); + const quotedStatusId = state.compose.get('quoted_status_id') as + | string + | null; + if (!quotedStatusId) { + return visibility; + } + + // Remove the quoted status + dispatch(quoteComposeCancel()); + const quotedStatus = state.statuses.get(quotedStatusId) as Status | null; + if (!quotedStatus) { + return visibility; + } + + // Append the quoted status URL to the compose text + const url = quotedStatus.get('url') as string; + const text = state.compose.get('text') as string; + if (!text.includes(url)) { + const newText = text.trim() ? `${text}\n\n${url}` : url; + dispatch(changeCompose(newText)); + } + return visibility; + }, +); + export const changeUploadCompose = createDataLoadingThunk( 'compose/changeUpload', async ( @@ -93,7 +138,7 @@ export const changeUploadCompose = createDataLoadingThunk( (media: SimulatedMediaAttachmentJSON) => { return { media, - attached: typeof media.unattached !== 'undefined' && !media.unattached, + attached: typeof media.attached !== 'undefined' && media.attached, }; }, { @@ -122,7 +167,11 @@ export const quoteComposeByStatus = createAppThunk( false, ); - if (composeState.get('poll')) { + if (composeState.get('id')) { + dispatch(showAlert({ message: messages.quoteErrorEdit })); + } else if (composeState.get('privacy') === 'direct') { + dispatch(showAlert({ message: messages.quoteErrorPrivateMention })); + } else if (composeState.get('poll')) { dispatch(showAlert({ message: messages.quoteErrorPoll })); } else if ( composeState.get('is_uploading') || @@ -165,8 +214,67 @@ export const quoteComposeById = createAppThunk( }, ); +const composeStateForbidsLink = (composeState: RootState['compose']) => { + return ( + composeState.get('quoted_status_id') || + composeState.get('is_submitting') || + composeState.get('poll') || + composeState.get('is_uploading') || + composeState.get('id') || + composeState.get('privacy') === 'direct' + ); +}; + +export const pasteLinkCompose = createDataLoadingThunk( + 'compose/pasteLink', + async ({ url }: { url: string }) => { + return await apiGetSearch({ + q: url, + type: 'statuses', + resolve: true, + limit: 2, + }); + }, + (data, { dispatch, getState, requestId }) => { + const composeState = getState().compose; + + if ( + composeStateForbidsLink(composeState) || + composeState.get('fetching_link') !== requestId // Request has been cancelled + ) + return; + + dispatch(importFetchedStatuses(data.statuses)); + + if ( + data.statuses.length === 1 && + data.statuses[0] && + ['automatic', 'manual'].includes( + data.statuses[0].quote_approval?.current_user ?? 'denied', + ) + ) { + dispatch(quoteComposeById(data.statuses[0].id)); + } + }, + { + useLoadingBar: false, + condition: (_, { getState }) => + !getState().compose.get('fetching_link') && + !composeStateForbidsLink(getState().compose), + }, +); + +// Ideally this would cancel the action and the HTTP request, but this is good enough +export const cancelPasteLinkCompose = createAction( + 'compose/cancelPasteLinkCompose', +); + export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); export const setComposeQuotePolicy = createAction( 'compose/setQuotePolicy', ); + +export const setDragUploadEnabled = createAction( + 'compose/setDragUploadEnabled', +); diff --git a/app/javascript/flavours/glitch/actions/custom_emojis.js b/app/javascript/flavours/glitch/actions/custom_emojis.js deleted file mode 100644 index fb65f072dc8db7..00000000000000 --- a/app/javascript/flavours/glitch/actions/custom_emojis.js +++ /dev/null @@ -1,40 +0,0 @@ -import api from '../api'; - -export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST'; -export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; -export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; - -export function fetchCustomEmojis() { - return (dispatch) => { - dispatch(fetchCustomEmojisRequest()); - - api().get('/api/v1/custom_emojis').then(response => { - dispatch(fetchCustomEmojisSuccess(response.data)); - }).catch(error => { - dispatch(fetchCustomEmojisFail(error)); - }); - }; -} - -export function fetchCustomEmojisRequest() { - return { - type: CUSTOM_EMOJIS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchCustomEmojisSuccess(custom_emojis) { - return { - type: CUSTOM_EMOJIS_FETCH_SUCCESS, - custom_emojis, - skipLoading: true, - }; -} - -export function fetchCustomEmojisFail(error) { - return { - type: CUSTOM_EMOJIS_FETCH_FAIL, - error, - skipLoading: true, - }; -} diff --git a/app/javascript/flavours/glitch/actions/directory.ts b/app/javascript/flavours/glitch/actions/directory.ts index 3e0f1356b3ae05..2cbfadec5667dd 100644 --- a/app/javascript/flavours/glitch/actions/directory.ts +++ b/app/javascript/flavours/glitch/actions/directory.ts @@ -6,15 +6,17 @@ import { createDataLoadingThunk } from 'flavours/glitch/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/flavours/glitch/actions/importer/emoji.ts b/app/javascript/flavours/glitch/actions/importer/emoji.ts new file mode 100644 index 00000000000000..8bc6bbbcb3585b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/emoji.ts @@ -0,0 +1,27 @@ +import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji'; +import { loadCustomEmoji } from '@/flavours/glitch/features/emoji'; +import { emojiLogger } from '@/flavours/glitch/features/emoji/utils'; + +const log = emojiLogger('actions'); + +export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { + if (emojis.length === 0) { + return; + } + + // First, check if we already have them all. + const { searchCustomEmojisByShortcodes, clearCache } = + await import('@/flavours/glitch/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 > 0 && existingEmojis.length < emojis.length) { + await clearCache('custom'); + await loadCustomEmoji(); + + log('Custom emojis updated, reloaded cache and picker data.'); + } +} diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index f1aabe27474aa1..83d09cb946dad2 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -1,8 +1,10 @@ import { createPollFromServerJSON } from 'flavours/glitch/models/poll'; import { importAccounts } from './accounts'; +import { importCustomEmoji } from './emoji'; import { normalizeStatus } from './normalizer'; import { importPolls } from './polls'; +import { fetchAccountsForCollectionPreview } from '@/flavours/glitch/reducers/slices/collections'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; @@ -39,6 +41,10 @@ export function importFetchedAccounts(accounts) { if (account.moved) { processAccount(account.moved); } + + if (account.emojis && account.username === account.acct) { + importCustomEmoji(account.emojis); + } } accounts.forEach(processAccount); @@ -46,19 +52,20 @@ export function importFetchedAccounts(accounts) { return importAccounts({ accounts: normalAccounts }); } -export function importFetchedStatus(status) { - return importFetchedStatuses([status]); +export function importFetchedStatus(status, options = {}) { + return importFetchedStatuses([status], options); } -export function importFetchedStatuses(statuses) { +export function importFetchedStatuses(statuses, options = {}) { return (dispatch, getState) => { const accounts = []; const normalStatuses = []; const polls = []; const filters = []; + const collections = []; function processStatus(status) { - pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings'))); + pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), { ...options, settings: getState().get('local_settings') })); pushUnique(accounts, status.account); if (status.filtered) { @@ -77,9 +84,17 @@ export function importFetchedStatuses(statuses) { pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id])); } + if (status.tagged_collections.length) { + status.tagged_collections.forEach(collection => pushUnique(collections, collection)); + } + 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); @@ -88,5 +103,6 @@ export function importFetchedStatuses(statuses) { dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); dispatch(importFilters(filters)); + fetchAccountsForCollectionPreview(collections, dispatch); }; } diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index e9972aa90c49e1..366375c3f634f3 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -1,10 +1,9 @@ import escapeTextContentForBrowser from 'escape-html'; -import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji'; - -import emojify from '../../features/emoji/emoji'; import { autoHideCW } from '../../utils/content_warning'; +import { importCustomEmoji } from './emoji'; + const domParser = new DOMParser(); export function searchTextFromRawStatus (status) { @@ -30,9 +29,12 @@ function stripQuoteFallback(text) { return wrapper.innerHTML; } -export function normalizeStatus(status, normalOldStatus, settings) { +export function normalizeStatus(status, normalOldStatus, { settings, bogusQuotePolicy = false }) { const normalStatus = { ...status }; + if (bogusQuotePolicy) + normalStatus.quote_approval = null; + normalStatus.account = status.account.id; if (status.reblog && status.reblog.id) { @@ -80,11 +82,10 @@ export function normalizeStatus(status, normalOldStatus, settings) { } else { const spoilerText = normalStatus.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); - const emojiMap = makeEmojiMap(normalStatus.emojis); normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; - normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); - normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); + normalStatus.contentHtml = normalStatus.content; + normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText); normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText); // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins @@ -105,6 +106,8 @@ export function normalizeStatus(status, normalOldStatus, settings) { } if (normalOldStatus) { + normalStatus.quote_approval ||= normalOldStatus.get('quote_approval'); + const list = normalOldStatus.get('media_attachments'); if (normalStatus.media_attachments && list) { normalStatus.media_attachments.forEach(item => { @@ -120,14 +123,12 @@ export function normalizeStatus(status, normalOldStatus, settings) { } export function normalizeStatusTranslation(translation, status) { - const emojiMap = makeEmojiMap(status.get('emojis').toJS()); - const normalTranslation = { detected_source_language: translation.detected_source_language, language: translation.language, provider: translation.provider, - contentHtml: emojify(translation.content, emojiMap), - spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap), + contentHtml: translation.content, + spoilerHtml: escapeTextContentForBrowser(translation.spoiler_text), spoiler_text: translation.spoiler_text, }; @@ -141,9 +142,12 @@ export function normalizeStatusTranslation(translation, status) { export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; - const emojiMap = makeEmojiMap(normalAnnouncement.emojis); - normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); + normalAnnouncement.contentHtml = normalAnnouncement.content; + + if (normalAnnouncement.emojis) { + importCustomEmoji(normalAnnouncement.emojis); + } return normalAnnouncement; } diff --git a/app/javascript/flavours/glitch/actions/importer/statuses.ts b/app/javascript/flavours/glitch/actions/importer/statuses.ts new file mode 100644 index 00000000000000..ff6dec75398a4c --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/statuses.ts @@ -0,0 +1,187 @@ +import escapeTextContentForBrowser from 'escape-html'; + +import type { ApiMediaAttachmentJSON } from '@/flavours/glitch/api_types/media_attachments'; +import type { + ApiFilterResultJSON, + ApiStatusJSON, +} from '@/flavours/glitch/api_types/statuses'; +import type { + FilterResult, + MediaAttachmentShape, + StatusShape, +} from '@/flavours/glitch/models/status'; + +const domParser = new DOMParser(); + +export function normalizeStatus( + status: ApiStatusJSON, + normalOldStatus?: StatusShape, + { bogusQuotePolicy = false, expandSpoilers = false } = {}, +) { + const normalStatus: StatusShape = { + hidden: normalOldStatus?.hidden ?? false, + collapsed: normalOldStatus?.collapsed ?? false, + content: '', + contentHtml: '', + muted: false, + pinned: false, + bookmarked: false, + favourited: false, + quote_approval: null, + reblogged: false, + + ...status, + + account: status.account.id, + media_attachments: [], + poll: status.poll?.id, + reblog: status.reblog?.id, + quote: undefined, + filtered: [], + application: { + name: 'Web', + ...status.application, + }, + }; + + if (bogusQuotePolicy) { + normalStatus.quote_approval = null; + } + + if (status.quote?.quoted_status) { + normalStatus.quote = { + ...status.quote, + quoted_status: status.quote.quoted_status.id, + }; + } + + if (status.card) { + normalStatus.card = { + ...status.card, + authors: status.card.authors.map((author) => ({ + name: author.name, + url: author.url, + accountId: author.account?.id, + })), + }; + } + + if (status.filtered) { + normalStatus.filtered = status.filtered.map(normalizeFilterResult); + } + + // Only calculate these values when status first encountered and + // when the underlying values change. Otherwise keep the ones + // already in the reducer + if ( + normalOldStatus?.content === normalStatus.content && + normalOldStatus.spoiler_text === normalStatus.spoiler_text + ) { + normalStatus.search_index = normalOldStatus.search_index; + normalStatus.contentHtml = normalOldStatus.contentHtml; + normalStatus.spoilerHtml = normalOldStatus.spoilerHtml; + normalStatus.spoiler_text = normalOldStatus.spoiler_text; + normalStatus.hidden = normalOldStatus.hidden; + + if (normalOldStatus.translation) { + normalStatus.translation = normalOldStatus.translation; + } + } else { + // If the status has a CW but no contents, treat the CW as if it were the + // status' contents, to avoid having a CW toggle with seemingly no effect. + if ( + normalStatus.spoiler_text && + !normalStatus.content && + !normalStatus.quote + ) { + normalStatus.content = normalStatus.spoiler_text; + normalStatus.spoiler_text = ''; + } + + const spoilerText = normalStatus.spoiler_text ?? ''; + const searchContent = [spoilerText, status.content] + .concat( + status.poll?.options + ? status.poll.options.map((option) => option.title) + : [], + ) + .concat(status.media_attachments.map((att) => att.description)) + .join('\n\n') + .replace(//g, '\n') + .replace(/<\/p>

/g, '\n\n'); + + normalStatus.search_index = domParser.parseFromString( + searchContent, + 'text/html', + ).documentElement.textContent; + normalStatus.contentHtml = normalStatus.content; + normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText); + normalStatus.hidden = expandSpoilers + ? false + : spoilerText.length > 0 || normalStatus.sensitive; + + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (normalStatus.quote && normalStatus.contentHtml) { + normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml); + } + + if ( + normalStatus.url && + !( + normalStatus.url.startsWith('http://') || + normalStatus.url.startsWith('https://') + ) + ) { + normalStatus.url = null; + } + + normalStatus.url ??= normalStatus.uri; + + normalStatus.media_attachments = status.media_attachments.map( + (attachment) => + normalizeMediaAttachment( + attachment, + normalOldStatus?.media_attachments, + ), + ); + } + + if (normalOldStatus) { + normalStatus.quote_approval ??= normalOldStatus.quote_approval; + } + + return normalStatus; +} + +function normalizeMediaAttachment( + attachment: ApiMediaAttachmentJSON, + oldAttachments?: MediaAttachmentShape[], +): MediaAttachmentShape { + const { remote_url } = attachment; + return { + ...attachment, + remote_url: + remote_url && /^https?:\/\//.test(remote_url) ? remote_url : null, + translation: oldAttachments?.find( + (oldAttachment) => + oldAttachment.id === attachment.id && + oldAttachment.description === attachment.description, + )?.translation, + }; +} + +function normalizeFilterResult(input: ApiFilterResultJSON): FilterResult { + return { + ...input, + filter: input.filter.id, + }; +} + +function stripQuoteFallback(text: string) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = text; + + wrapper.querySelector('.quote-inline')?.remove(); + + return wrapper.innerHTML; +} diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 92142d782c1c90..68be9a9db6590e 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -6,6 +6,10 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; +import { + insertPinnedStatusIntoTimelines, + removePinnedStatusFromTimelines, +} from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -368,6 +372,7 @@ export function pin(status) { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); + dispatch(insertPinnedStatusIntoTimelines(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -406,6 +411,7 @@ export function unpin (status) { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); + dispatch(removePinnedStatusFromTimelines(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/flavours/glitch/actions/modal.ts b/app/javascript/flavours/glitch/actions/modal.ts index 9e653c5f4c93f2..947c3eac412376 100644 --- a/app/javascript/flavours/glitch/actions/modal.ts +++ b/app/javascript/flavours/glitch/actions/modal.ts @@ -10,6 +10,7 @@ interface OpenModalPayload { modalType: ModalType; modalProps: ModalProps; previousModalProps?: ModalProps; + ignoreFocus?: boolean; } export const openModal = createAction('MODAL_OPEN'); diff --git a/app/javascript/flavours/glitch/actions/notification_groups.ts b/app/javascript/flavours/glitch/actions/notification_groups.ts index b1458984948369..ea37c4e0ce7848 100644 --- a/app/javascript/flavours/glitch/actions/notification_groups.ts +++ b/app/javascript/flavours/glitch/actions/notification_groups.ts @@ -5,6 +5,7 @@ import { apiFetchNotificationGroups, } from 'flavours/glitch/api/notifications'; import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { ApiNotificationGroupJSON, ApiNotificationJSON, @@ -26,6 +27,8 @@ import { createDataLoadingThunk, } from 'flavours/glitch/store/typed_functions'; +import { fetchAccountsForCollectionPreview } from '../reducers/slices/collections'; + import { importFetchedAccounts, importFetchedStatuses } from './importer'; import { NOTIFICATIONS_FILTER_SET } from './notifications'; import { saveSettings } from './settings'; @@ -36,9 +39,18 @@ function notificationTypeForFilter(type: NotificationType) { } function notificationTypeForQuickFilter(type: NotificationType) { - if (type === 'quoted_update') return 'update'; - else if (type === 'quote') return 'mention'; - else return type; + switch (type) { + case 'quoted_update': + return 'update'; + case 'quote': + return 'mention'; + case 'collection_update': + return 'collection'; + case 'added_to_collection': + return 'collection'; + default: + return type; + } } function excludeAllTypesExcept(filter: string) { @@ -61,6 +73,7 @@ function dispatchAssociatedRecords( ) { const fetchedAccounts: ApiAccountJSON[] = []; const fetchedStatuses: ApiStatusJSON[] = []; + const collections: ApiCollectionJSON[] = []; notifications.forEach((notification) => { if (notification.type === 'admin.report') { @@ -74,6 +87,10 @@ function dispatchAssociatedRecords( if ('status' in notification && notification.status) { fetchedStatuses.push(notification.status); } + + if ('collection' in notification && notification.collection) { + collections.push(notification.collection); + } }); if (fetchedAccounts.length > 0) @@ -81,6 +98,9 @@ function dispatchAssociatedRecords( if (fetchedStatuses.length > 0) dispatch(importFetchedStatuses(fetchedStatuses)); + + if (collections.length > 0) + void fetchAccountsForCollectionPreview(collections, dispatch); } function selectNotificationGroupedTypes(state: RootState) { diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 558390b9cffac0..cb4cd1251c41fe 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -26,8 +26,10 @@ defineMessages({ export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { - const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); - const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + const filterType = notification.type === 'quoted_update' ? 'update' : notification.type; + + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', filterType], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', filterType], true); let filtered = false; @@ -84,6 +86,9 @@ export function setupBrowserNotifications() { }; } +/** + * @param {(NotificationPermission) => void} callback + */ export function requestBrowserPermission(callback = noOp) { return dispatch => { requestNotificationPermission((permission) => { diff --git a/app/javascript/flavours/glitch/actions/search.ts b/app/javascript/flavours/glitch/actions/search.ts index d0c3e01c7701b2..6d1080026af993 100644 --- a/app/javascript/flavours/glitch/actions/search.ts +++ b/app/javascript/flavours/glitch/actions/search.ts @@ -12,6 +12,11 @@ import { createAppAsyncThunk, } from 'flavours/glitch/store/typed_functions'; +import { + fetchAccountsForCollectionPreview, + importFetchedCollections, +} from '../reducers/slices/collections'; + import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatuses } from './importer'; @@ -29,7 +34,7 @@ export const submitSearch = createDataLoadingThunk( limit: 11, }); }, - (data, { dispatch }) => { + async (data, { dispatch }) => { if (data.accounts.length > 0) { dispatch(importFetchedAccounts(data.accounts)); dispatch(fetchRelationships(data.accounts.map((account) => account.id))); @@ -39,6 +44,11 @@ export const submitSearch = createDataLoadingThunk( dispatch(importFetchedStatuses(data.statuses)); } + if (data.collections.length > 0) { + dispatch(importFetchedCollections(data.collections)); + await fetchAccountsForCollectionPreview(data.collections, dispatch); + } + return data; }, { @@ -60,7 +70,7 @@ export const expandSearch = createDataLoadingThunk( offset, }); }, - (data, { dispatch }) => { + async (data, { dispatch }) => { if (data.accounts.length > 0) { dispatch(importFetchedAccounts(data.accounts)); dispatch(fetchRelationships(data.accounts.map((account) => account.id))); @@ -70,6 +80,11 @@ export const expandSearch = createDataLoadingThunk( dispatch(importFetchedStatuses(data.statuses)); } + if (data.collections.length > 0) { + dispatch(importFetchedCollections(data.collections)); + await fetchAccountsForCollectionPreview(data.collections, dispatch); + } + return data; }, { @@ -147,7 +162,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/flavours/glitch/actions/server.js b/app/javascript/flavours/glitch/actions/server.js deleted file mode 100644 index 32ee093afa8423..00000000000000 --- a/app/javascript/flavours/glitch/actions/server.js +++ /dev/null @@ -1,131 +0,0 @@ -import api from '../api'; - -import { importFetchedAccount } from './importer'; - -export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST'; -export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS'; -export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL'; - -export const SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST = 'SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST'; -export const SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS = 'SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS'; -export const SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL = 'SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL'; - -export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST'; -export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS'; -export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL'; - -export const SERVER_DOMAIN_BLOCKS_FETCH_REQUEST = 'SERVER_DOMAIN_BLOCKS_FETCH_REQUEST'; -export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS'; -export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL'; - -export const fetchServer = () => (dispatch, getState) => { - if (getState().getIn(['server', 'server', 'isLoading'])) { - return; - } - - dispatch(fetchServerRequest()); - - api() - .get('/api/v2/instance').then(({ data }) => { - if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); - dispatch(fetchServerSuccess(data)); - }).catch(err => dispatch(fetchServerFail(err))); -}; - -const fetchServerRequest = () => ({ - type: SERVER_FETCH_REQUEST, -}); - -const fetchServerSuccess = server => ({ - type: SERVER_FETCH_SUCCESS, - server, -}); - -const fetchServerFail = error => ({ - type: SERVER_FETCH_FAIL, - error, -}); - -export const fetchServerTranslationLanguages = () => (dispatch) => { - dispatch(fetchServerTranslationLanguagesRequest()); - - api() - .get('/api/v1/instance/translation_languages').then(({ data }) => { - dispatch(fetchServerTranslationLanguagesSuccess(data)); - }).catch(err => dispatch(fetchServerTranslationLanguagesFail(err))); -}; - -const fetchServerTranslationLanguagesRequest = () => ({ - type: SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST, -}); - -const fetchServerTranslationLanguagesSuccess = translationLanguages => ({ - type: SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS, - translationLanguages, -}); - -const fetchServerTranslationLanguagesFail = error => ({ - type: SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL, - error, -}); - -export const fetchExtendedDescription = () => (dispatch, getState) => { - if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) { - return; - } - - dispatch(fetchExtendedDescriptionRequest()); - - api() - .get('/api/v1/instance/extended_description') - .then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data))) - .catch(err => dispatch(fetchExtendedDescriptionFail(err))); -}; - -const fetchExtendedDescriptionRequest = () => ({ - type: EXTENDED_DESCRIPTION_REQUEST, -}); - -const fetchExtendedDescriptionSuccess = description => ({ - type: EXTENDED_DESCRIPTION_SUCCESS, - description, -}); - -const fetchExtendedDescriptionFail = error => ({ - type: EXTENDED_DESCRIPTION_FAIL, - error, -}); - -export const fetchDomainBlocks = () => (dispatch, getState) => { - if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) { - return; - } - - dispatch(fetchDomainBlocksRequest()); - - api() - .get('/api/v1/instance/domain_blocks') - .then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data))) - .catch(err => { - if (err.response.status === 404) { - dispatch(fetchDomainBlocksSuccess(false, [])); - } else { - dispatch(fetchDomainBlocksFail(err)); - } - }); -}; - -const fetchDomainBlocksRequest = () => ({ - type: SERVER_DOMAIN_BLOCKS_FETCH_REQUEST, -}); - -const fetchDomainBlocksSuccess = (isAvailable, blocks) => ({ - type: SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS, - isAvailable, - blocks, -}); - -const fetchDomainBlocksFail = error => ({ - type: SERVER_DOMAIN_BLOCKS_FETCH_FAIL, - error, -}); diff --git a/app/javascript/flavours/glitch/actions/server.ts b/app/javascript/flavours/glitch/actions/server.ts new file mode 100644 index 00000000000000..4b5103de72140f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/server.ts @@ -0,0 +1,48 @@ +import { + apiGetInstance, + apiGetExtendedDescription, + apiGetDomainBlocks, + apiGetTranslationLanguages, +} from 'flavours/glitch/api/instance'; +import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; + +import { importFetchedAccount } from './importer'; + +export const fetchServer = createDataLoadingThunk( + 'server/fetch', + () => apiGetInstance(), + (instance, { dispatch }) => { + if (instance.contact.account) { + dispatch(importFetchedAccount(instance.contact.account)); + } + }, + { + condition: (_, { getState }) => !getState().server.server.isLoading, + }, +); + +export const fetchExtendedDescription = createDataLoadingThunk( + 'server/extended_description', + () => apiGetExtendedDescription(), + { + condition: (_, { getState }) => + !getState().server.extendedDescription.isLoading, + }, +); + +export const fetchServerTranslationLanguages = createDataLoadingThunk( + 'server/translation_languages', + () => apiGetTranslationLanguages(), + { + condition: (_, { getState }) => + !getState().server.translationLanguages.isLoading, + }, +); + +export const fetchDomainBlocks = createDataLoadingThunk( + 'server/domain_blocks', + () => apiGetDomainBlocks(), + { + condition: (_, { getState }) => !getState().server.domainBlocks.isLoading, + }, +); diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index bfb20dc4014a88..2416e4e104c263 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/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)); }); }; } @@ -107,14 +109,15 @@ export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) { }; } -export function redraft(status, raw_text, content_type) { +export function redraft(status, raw_text, content_type, quoted_status_id = null) { return (dispatch, getState) => { - const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']); + const maxOptions = getState().server.server.item?.configuration.polls.max_options; dispatch({ type: REDRAFT, status, raw_text, + quoted_status_id, content_type, maxOptions, }); @@ -133,7 +136,7 @@ export const editStatus = (id) => (dispatch, getState) => { api().get(`/api/v1/statuses/${id}/source`).then(response => { dispatch(fetchStatusSourceSuccess()); ensureComposeIsVisible(getState); - dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type)); + dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type, response.data.quote?.quoted_status?.id)); }).catch(error => { dispatch(fetchStatusSourceFail(error)); }); @@ -204,8 +207,8 @@ export function deleteStatusFail(id, error) { }; } -export const updateStatus = status => dispatch => - dispatch(importFetchedStatus(status)); +export const updateStatus = (status, { bogusQuotePolicy }) => dispatch => + dispatch(importFetchedStatus(status, { bogusQuotePolicy })); export function muteStatus(id) { return (dispatch) => { diff --git a/app/javascript/flavours/glitch/actions/statuses_typed.ts b/app/javascript/flavours/glitch/actions/statuses_typed.ts index 4472cbad2540bc..039fbf3ac5110a 100644 --- a/app/javascript/flavours/glitch/actions/statuses_typed.ts +++ b/app/javascript/flavours/glitch/actions/statuses_typed.ts @@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( 'status/context', - ({ statusId }: { statusId: string }) => apiGetContext(statusId), - ({ context, refresh }, { dispatch }) => { + ({ statusId }: { statusId: string; prefetchOnly?: boolean }) => + apiGetContext(statusId), + ({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => { const statuses = context.ancestors.concat(context.descendants); dispatch(importFetchedStatuses(statuses)); @@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk( return { context, refresh, + prefetchOnly, }; }, ); @@ -26,6 +28,14 @@ export const completeContextRefresh = createAction<{ statusId: string }>( 'status/context/complete', ); +export const showPendingReplies = createAction<{ statusId: string }>( + 'status/context/showPendingReplies', +); + +export const clearPendingReplies = createAction<{ statusId: string }>( + 'status/context/clearPendingReplies', +); + export const setStatusQuotePolicy = createDataLoadingThunk( 'status/setQuotePolicy', ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => { diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js index b2d19575c4c5d6..2a45373c9e2545 100644 --- a/app/javascript/flavours/glitch/actions/store.js +++ b/app/javascript/flavours/glitch/actions/store.js @@ -37,7 +37,9 @@ export function hydrateStore(rawState) { dispatch(hydrateCompose()); dispatch(hydrateSearch()); - dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + if (rawState.accounts) { + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + } dispatch(saveSettings()); }; } diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 93bd7dc5f44098..f4bd0ff373264e 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -32,27 +32,38 @@ import { const randomUpTo = max => Math.floor(Math.random() * Math.floor(max)); +/** + * @typedef {import('flavours/glitch/store').AppDispatch} Dispatch + * @typedef {import('flavours/glitch/store').GetState} GetState + * @typedef {import('redux').UnknownAction} UnknownAction + * @typedef {function(Dispatch, GetState): Promise} FallbackFunction + */ + /** * @param {string} timelineId * @param {string} channelName * @param {Object.} params * @param {Object} options - * @param {function(Function, Function): Promise} [options.fallback] - * @param {function(): void} [options.fillGaps] + * @param {FallbackFunction} [options.fallback] + * @param {function(): UnknownAction} [options.fillGaps] * @param {function(object): boolean} [options.accept] * @returns {function(): void} */ export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => { const { messages } = getLocale(); + // Public streams are currently not returning personalized quote policies + const bogusQuotePolicy = channelName.startsWith('public') || channelName.startsWith('hashtag'); + return connectStream(channelName, params, (dispatch, getState) => { + // @ts-ignore const locale = getState().getIn(['meta', 'locale']); // @ts-expect-error let pollingId; /** - * @param {function(Function, Function): Promise} fallback + * @param {FallbackFunction} fallback */ const useFallback = async fallback => { @@ -89,11 +100,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti switch (data.event) { case 'update': // @ts-expect-error - dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); + dispatch(updateTimeline(timelineId, JSON.parse(data.payload), { accept: options.accept, bogusQuotePolicy })); break; case 'status.update': // @ts-expect-error - dispatch(updateStatus(JSON.parse(data.payload))); + dispatch(updateStatus(JSON.parse(data.payload), { bogusQuotePolicy })); break; case 'delete': dispatch(deleteFromTimelines(data.payload)); @@ -132,7 +143,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti }; /** - * @param {Function} dispatch + * @param {Dispatch} dispatch */ async function refreshHomeTimelineAndNotification(dispatch) { await dispatch(expandHomeTimeline({ maxId: undefined })); @@ -151,7 +162,11 @@ async function refreshHomeTimelineAndNotification(dispatch) { * @returns {function(): void} */ export const connectUserStream = () => - connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps }); + connectTimelineStream('home', 'user', {}, { + fallback: refreshHomeTimelineAndNotification, + // @ts-expect-error + fillGaps: fillHomeTimelineGaps + }); /** * @param {Object} options @@ -159,7 +174,10 @@ export const connectUserStream = () => * @returns {function(): void} */ export const connectCommunityStream = ({ onlyMedia } = {}) => - connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) }); + connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { + // @ts-expect-error + fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) + }); /** * @param {Object} options @@ -169,7 +187,10 @@ export const connectCommunityStream = ({ onlyMedia } = {}) => * @returns {function(): void} */ export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => - connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly }) }); + connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, { + // @ts-expect-error + fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly }) + }); /** * @param {string} columnId @@ -192,4 +213,7 @@ export const connectDirectStream = () => * @returns {function(): void} */ export const connectListStream = listId => - connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) }); + connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { + // @ts-expect-error + fillGaps: () => fillListTimelineGaps(listId) + }); diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index 1d5a696c92da5b..f9957ab015378b 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -7,7 +7,7 @@ import { toServerSideType } from 'flavours/glitch/utils/filters'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { submitMarkers } from './markers'; -import {timelineDelete} from './timelines_typed'; +import { timelineDelete } from './timelines_typed'; export { disconnectTimeline } from './timelines_typed'; @@ -25,15 +25,23 @@ 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, timeline, }); -export function updateTimeline(timeline, status, accept) { +export function updateTimeline(timeline, status, { accept = undefined, bogusQuotePolicy = false } = {}) { return (dispatch, getState) => { if (typeof accept === 'function' && !accept(status)) { return; @@ -55,7 +63,7 @@ export function updateTimeline(timeline, status, accept) { filtered = filters.length > 0; } - dispatch(importFetchedStatus(status)); + dispatch(importFetchedStatus(status, { bogusQuotePolicy })); dispatch({ type: TIMELINE_UPDATE, @@ -162,7 +170,7 @@ export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}) => expa export const expandDirectTimeline = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }); export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId }); export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); +export const expandAccountMediaTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}:media${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, exclude_replies: !withReplies }); export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); export const expandLinkTimeline = (url, { maxId } = {}) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }); export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}) => { diff --git a/app/javascript/flavours/glitch/actions/timelines.test.ts b/app/javascript/flavours/glitch/actions/timelines.test.ts new file mode 100644 index 00000000000000..239692dd34faa5 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/timelines_typed.ts b/app/javascript/flavours/glitch/actions/timelines_typed.ts index 485b94ed524fd3..a13522c60ce980 100644 --- a/app/javascript/flavours/glitch/actions/timelines_typed.ts +++ b/app/javascript/flavours/glitch/actions/timelines_typed.ts @@ -1,7 +1,191 @@ import { createAction } from '@reduxjs/toolkit'; +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; +import type { Status } from '../models/status'; +import { createAppThunk } from '../store/typed_functions'; + +import { + expandTimeline, + insertIntoTimeline, + 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, accountId?: string) { + const parsedKey = parseTimelineKey(key); + const isPinned = parsedKey?.type === 'account' && parsedKey.pinned; + if (!accountId || !isPinned) { + return isPinned; + } + return parsedKey.userId === accountId; +} + +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 +202,72 @@ export const timelineDelete = createAction<{ references: string[]; reblogOf: string | null; }>('timelines/delete'); + +export const timelineDeleteStatus = createAction<{ + statusId: string; + timelineKey: string; +}>('timelines/deleteStatus'); + +export const insertPinnedStatusIntoTimelines = createAppThunk( + (status: Status, { dispatch, getState }) => { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { + return; + } + + const tags = + ( + status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined + ) + ?.map((tag) => tag.get('name') as string) + .toArray() ?? []; + + const timelines = getState().timelines as ImmutableMap; + const accountTimelines = timelines.filter((_, key) => { + if (!key.startsWith(`account:${currentAccountId}:`)) { + return false; + } + const parsed = parseTimelineKey(key); + const isPinned = parsed?.type === 'account' && parsed.pinned; + if (!isPinned) { + return false; + } + + return !parsed.tagged || tags.includes(parsed.tagged); + }); + + accountTimelines.forEach((_, key) => { + dispatch(insertIntoTimeline(key, status.get('id') as string, 0)); + }); + }, +); + +export const removePinnedStatusFromTimelines = createAppThunk( + (status: Status, { dispatch, getState }) => { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { + return; + } + + const statusId = status.get('id') as string; + const timelines = getState().timelines as ImmutableMap< + string, + ImmutableMap<'items' | 'pendingItems', ImmutableList> + >; + + timelines.forEach((timeline, key) => { + if (!isTimelineKeyPinned(key, currentAccountId)) { + return; + } + + if ( + timeline.get('items')?.includes(statusId) || + timeline.get('pendingItems')?.includes(statusId) + ) { + dispatch(timelineDeleteStatus({ statusId, timelineKey: key })); + } + }); + }, +); diff --git a/app/javascript/flavours/glitch/api.ts b/app/javascript/flavours/glitch/api.ts index ca6dec0974884d..0c4e24e5c3fa4b 100644 --- a/app/javascript/flavours/glitch/api.ts +++ b/app/javascript/flavours/glitch/api.ts @@ -107,15 +107,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; } = {}, ) { @@ -128,30 +131,41 @@ export async function apiRequest( return data; } -export async function apiRequestGet( +export async function apiRequestGet( url: ApiUrl, - params?: RequestParamsOrData, + params?: RequestParamsOrData, + args: { + signal?: AbortSignal; + timeout?: number; + } = {}, ) { - return apiRequest('GET', url, { params }); + return apiRequest('GET', url, { params, ...args }); } -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( +export async function apiRequestDelete< + ApiResponse = unknown, + ApiParams = unknown, +>(url: ApiUrl, params?: RequestParamsOrData) { + return apiRequest('DELETE', url, { params }); +} + +export async function apiRequestPatch( url: ApiUrl, - params?: RequestParamsOrData, + data?: RequestParamsOrData, ) { - return apiRequest('DELETE', url, { params }); + return apiRequest('PATCH', url, { data }); } diff --git a/app/javascript/flavours/glitch/api/accounts.ts b/app/javascript/flavours/glitch/api/accounts.ts index 3752150448ffcd..1a5d3f13e4e6e7 100644 --- a/app/javascript/flavours/glitch/api/accounts.ts +++ b/app/javascript/flavours/glitch/api/accounts.ts @@ -1,10 +1,28 @@ -import { apiRequestPost, apiRequestGet } from 'flavours/glitch/api'; +import { + apiRequestPost, + apiRequestGet, + apiRequestDelete, + apiRequestPatch, +} from 'flavours/glitch/api'; import type { ApiAccountJSON, ApiFamiliarFollowersJSON, } from 'flavours/glitch/api_types/accounts'; import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; -import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags'; +import type { + ApiFeaturedTagJSON, + ApiHashtagJSON, +} from 'flavours/glitch/api_types/tags'; + +import type { + ApiProfileJSON, + ApiProfileUpdateParams, +} from '../api_types/profile'; + +export const apiGetAccounts = (ids: string[]) => + apiRequestGet('v1/accounts', { + id: ids, + }); export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { @@ -30,7 +48,19 @@ export const apiRemoveAccountFromFollowers = (id: string) => ); export const apiGetFeaturedTags = (id: string) => - apiRequestGet(`v1/accounts/${id}/featured_tags`); + apiRequestGet(`v1/accounts/${id}/featured_tags`); + +export const apiGetCurrentFeaturedTags = () => + apiRequestGet(`v1/featured_tags`); + +export const apiPostFeaturedTag = (name: string) => + apiRequestPost('v1/featured_tags', { name }); + +export const apiDeleteFeaturedTag = (id: string) => + apiRequestDelete(`v1/featured_tags/${id}`); + +export const apiGetTagSuggestions = () => + apiRequestGet('v1/featured_tags/suggestions'); export const apiGetEndorsedAccounts = (id: string) => apiRequestGet(`v1/accounts/${id}/endorsements`); @@ -39,3 +69,17 @@ export const apiGetFamiliarFollowers = (id: string) => apiRequestGet('v1/accounts/familiar_followers', { id, }); + +export const apiGetProfile = () => apiRequestGet('v1/profile'); + +export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) => + apiRequestPatch('v1/profile', params); + +export const apiDeleteProfileAvatar = () => + apiRequestDelete('v1/profile/avatar'); + +export const apiDeleteProfileHeader = () => + apiRequestDelete('v1/profile/header'); + +export const apiSubscribeByEmail = (id: string, email: string) => + apiRequestPost(`v1/accounts/${id}/email_subscriptions`, { email }); diff --git a/app/javascript/flavours/glitch/api/annual_report.ts b/app/javascript/flavours/glitch/api/annual_report.ts new file mode 100644 index 00000000000000..dc080035d49f8a --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api/collections.ts b/app/javascript/flavours/glitch/api/collections.ts new file mode 100644 index 00000000000000..521f5bf25a0457 --- /dev/null +++ b/app/javascript/flavours/glitch/api/collections.ts @@ -0,0 +1,54 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'flavours/glitch/api'; + +import type { + ApiWrappedCollectionJSON, + ApiCollectionWithAccountsJSON, + ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, + ApiCollectionsJSON, + WrappedCollectionAccountItem, +} from '../api_types/collections'; + +export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => + apiRequestPost('v1/collections', collection); + +export const apiUpdateCollection = ({ + id, + ...collection +}: ApiUpdateCollectionPayload) => + apiRequestPut(`v1/collections/${id}`, collection); + +export const apiDeleteCollection = (collectionId: string) => + apiRequestDelete(`v1/collections/${collectionId}`); + +export const apiGetCollection = (collectionId: string) => + apiRequestGet( + `v1/collections/${collectionId}`, + ); + +export const apiGetCollectionsCreatedByAccount = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/collections`); + +export const apiGetCollectionsFeaturingAccount = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/in_collections`); + +export const apiAddCollectionItem = (collectionId: string, accountId: string) => + apiRequestPost( + `v1/collections/${collectionId}/items`, + { account_id: accountId }, + ); + +export const apiRemoveCollectionItem = (collectionId: string, itemId: string) => + apiRequestDelete( + `v1/collections/${collectionId}/items/${itemId}`, + ); + +export const apiRevokeCollectionInclusion = ( + collectionId: string, + itemId: string, +) => apiRequestPost(`v1/collections/${collectionId}/items/${itemId}/revoke`); diff --git a/app/javascript/flavours/glitch/api/instance.ts b/app/javascript/flavours/glitch/api/instance.ts index 6d3c12a5dbf234..aecf27c61317c1 100644 --- a/app/javascript/flavours/glitch/api/instance.ts +++ b/app/javascript/flavours/glitch/api/instance.ts @@ -2,6 +2,10 @@ import { apiRequestGet } from 'flavours/glitch/api'; import type { ApiTermsOfServiceJSON, ApiPrivacyPolicyJSON, + ApiInstanceJSON, + ApiExtendedDescriptionJSON, + ApiTranslationLanguagesJSON, + ApiDomainBlockJSON, } from 'flavours/glitch/api_types/instance'; export const apiGetTermsOfService = (version?: string) => @@ -13,3 +17,17 @@ export const apiGetTermsOfService = (version?: string) => export const apiGetPrivacyPolicy = () => apiRequestGet('v1/instance/privacy_policy'); + +export const apiGetInstance = () => + apiRequestGet('v2/instance'); + +export const apiGetExtendedDescription = () => + apiRequestGet('v1/instance/extended_description'); + +export const apiGetTranslationLanguages = () => + apiRequestGet( + 'v1/instance/translation_languages', + ); + +export const apiGetDomainBlocks = () => + apiRequestGet('v1/instance/domain_blocks'); diff --git a/app/javascript/flavours/glitch/api/lists.ts b/app/javascript/flavours/glitch/api/lists.ts index 1b12c558c99aa2..fb05659e76d1c4 100644 --- a/app/javascript/flavours/glitch/api/lists.ts +++ b/app/javascript/flavours/glitch/api/lists.ts @@ -15,7 +15,7 @@ export const apiUpdate = (list: Partial) => export const apiGetLists = () => apiRequestGet('v1/lists'); -export const apiGetAccounts = (listId: string) => +export const apiGetListAccounts = (listId: string) => apiRequestGet(`v1/lists/${listId}/accounts`, { limit: 0, }); diff --git a/app/javascript/flavours/glitch/api/search.ts b/app/javascript/flavours/glitch/api/search.ts index 1b089d5f7ee1fc..2fd3cfba550b85 100644 --- a/app/javascript/flavours/glitch/api/search.ts +++ b/app/javascript/flavours/glitch/api/search.ts @@ -4,13 +4,22 @@ import type { ApiSearchResultsJSON, } from 'flavours/glitch/api_types/search'; -export const apiGetSearch = (params: { - q: string; - resolve?: boolean; - type?: ApiSearchType; - limit?: number; - offset?: number; -}) => - apiRequestGet('v2/search', { - ...params, - }); +export const apiGetSearch = ( + params: { + q: string; + resolve?: boolean; + type?: ApiSearchType; + limit?: number; + offset?: number; + }, + options: { + signal?: AbortSignal; + } = {}, +) => + apiRequestGet( + 'v2/search', + { + ...params, + }, + options, + ); diff --git a/app/javascript/flavours/glitch/api_types/accounts.ts b/app/javascript/flavours/glitch/api_types/accounts.ts index 913a201fef4d96..85ee62ad0757ce 100644 --- a/app/javascript/flavours/glitch/api_types/accounts.ts +++ b/app/javascript/flavours/glitch/api_types/accounts.ts @@ -12,26 +12,52 @@ export interface ApiAccountRoleJSON { name: string; } +type ApiFeaturePolicy = + | 'public' + | 'followers' + | 'following' + | 'disabled' + | 'unsupported_policy'; + +type ApiUserFeaturePolicy = + | 'automatic' + | 'manual' + | 'denied' + | 'missing' + | 'unknown'; + +interface ApiFeaturePolicyJSON { + automatic: ApiFeaturePolicy[]; + manual: ApiFeaturePolicy[]; + current_user: ApiUserFeaturePolicy; +} + // See app/serializers/rest/account_serializer.rb export interface BaseApiAccountJSON { acct: string; avatar: string; avatar_static: string; + avatar_description: string; bot: boolean; created_at: string; discoverable?: boolean; indexable: boolean; display_name: string; emojis: ApiCustomEmojiJSON[]; + feature_approval: ApiFeaturePolicyJSON; fields: ApiAccountFieldJSON[]; followers_count: number; following_count: number; group: boolean; header: string; header_static: string; + header_description: string; id: string; - last_status_at: string; + last_status_at: string | null; locked: boolean; + show_media: boolean; + show_media_replies: boolean; + show_featured: boolean; noindex?: boolean; note: string; roles?: ApiAccountJSON[]; @@ -44,6 +70,7 @@ export interface BaseApiAccountJSON { limited?: boolean; memorial?: boolean; hide_collections: boolean; + email_subscriptions?: boolean; } // See app/serializers/rest/muted_account_serializer.rb diff --git a/app/javascript/flavours/glitch/api_types/announcements.ts b/app/javascript/flavours/glitch/api_types/announcements.ts new file mode 100644 index 00000000000000..03e8922d8f189f --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/announcements.ts @@ -0,0 +1,28 @@ +// See app/serializers/rest/announcement_serializer.rb + +import type { ApiCustomEmojiJSON } from './custom_emoji'; +import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses'; + +export interface ApiAnnouncementJSON { + id: string; + content: string; + starts_at: null | string; + ends_at: null | string; + all_day: boolean; + published_at: string; + updated_at: null | string; + read: boolean; + mentions: ApiMentionJSON[]; + statuses: ApiStatusJSON[]; + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + reactions: ApiAnnouncementReactionJSON[]; +} + +export interface ApiAnnouncementReactionJSON { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; +} diff --git a/app/javascript/flavours/glitch/api_types/collections.ts b/app/javascript/flavours/glitch/api_types/collections.ts new file mode 100644 index 00000000000000..2ba20eb514c6a0 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/collections.ts @@ -0,0 +1,85 @@ +// 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; + url: string; + local: boolean; + item_count: number; + + name: string; + description: string | null; + tag: ApiTagJSON | null; + language: string | null; + 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 + */ +export interface CollectionAccountItem { + id: string; + account_id?: string; // Only present when state is 'accepted' (or the collection is your own) + state: 'pending' | 'accepted' | 'rejected' | 'revoked'; + created_at: string; +} + +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/flavours/glitch/api_types/custom_emoji.ts b/app/javascript/flavours/glitch/api_types/custom_emoji.ts index 05144d6f68d0e8..099ef0b88b8f25 100644 --- a/app/javascript/flavours/glitch/api_types/custom_emoji.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/errors.ts b/app/javascript/flavours/glitch/api_types/errors.ts new file mode 100644 index 00000000000000..46f8e0b8cdaa08 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/errors.ts @@ -0,0 +1,22 @@ +export type ErrorToken = + | 'ERR_TAKEN' + | 'ERR_INVALID' + | 'ERR_BLOCKED' + | 'ERR_RESERVED' + | 'ERR_TOO_MANY' + | 'ERR_MALFORMED' + | 'ERR_UNUSABLE' + | 'ERR_TOO_SOON' + | 'ERR_BELOW_LIMIT' + | 'ERR_UNREACHABLE' + | 'ERR_ELEVATED'; + +export interface ValidationError { + error: ErrorToken; + description: string; +} + +export interface ValidationErrorResponse { + error: string; + details: Record; +} diff --git a/app/javascript/flavours/glitch/api_types/instance.ts b/app/javascript/flavours/glitch/api_types/instance.ts index 3a29684b703170..361dade4c72681 100644 --- a/app/javascript/flavours/glitch/api_types/instance.ts +++ b/app/javascript/flavours/glitch/api_types/instance.ts @@ -1,3 +1,5 @@ +import type { ApiAccountJSON } from './accounts'; + export interface ApiTermsOfServiceJSON { effective_date: string; effective: boolean; @@ -9,3 +11,136 @@ export interface ApiPrivacyPolicyJSON { updated_at: string; content: string; } + +interface ApiBaseRuleJSON { + text: string; + hint: string; +} + +export interface ApiRuleJSON { + id: string; + text: string; + hint: string; + translations?: Record; +} + +export interface ApiExtendedDescriptionJSON { + updated_at: string; + content: string; +} + +export interface ApiDomainBlockJSON { + domain: string; + digest: string; + severity: string; + comment: string; +} + +export type ApiTranslationLanguagesJSON = Record; + +export interface ApiInstanceJSON { + domain: string; + title: string; + version: string; + source_url: string; + description: string; + languages: string[]; + usage: { + users: { + active_month: number; + }; + }; + thumbnail: { + url: string; + blurhash?: string; + description: string; + versions?: Record; + }; + contact: { + email: string | null; + account: ApiAccountJSON | null; + }; + api_versions: { + mastodon: number; + }; + registrations: { + enabled: boolean; + approval_required: boolean; + reason_required: boolean | null; + message: string | null; + min_age: string | null; + url: string | null; + }; + rules: ApiRuleJSON[]; + configuration: { + urls: { + streaming: string; + status: string | null; + about: string; + privacy_policy: string | null; + terms_of_service: string | null; + }; + + vapid: { + public_key: string; + }; + + accounts: { + max_display_name_length: number; + max_note_length: number; + max_avatar_description_length: number; + max_header_description_length: number; + max_featured_tags: number; + max_pinned_statuses: number; + max_profile_fields: number; + profile_field_name_limit: number; + profile_field_value_limit: number; + }; + + statuses: { + max_characters: number; + max_media_attachments: number; + characters_reserved_per_url: number; + }; + + media_attachments: { + description_limit: number; + image_matrix_limit: number; + image_size_limit: number; + supported_mime_types: string[]; + video_frame_rate_limit: number; + video_matrix_limit: number; + video_size_limit: number; + }; + + polls: { + max_options: number; + max_characters_per_option: number; + min_expiration: number; + max_expiration: number; + }; + + translation: { + enabled: boolean; + }; + + timeline_access: { + live_feeds: { + local: string; + remote: string; + }; + + hashtag_feeds: { + local: string; + remote: string; + }; + + trending_link_feeds: { + local: string; + remote: string; + }; + }; + + limited_federation: boolean; + }; +} diff --git a/app/javascript/flavours/glitch/api_types/media_attachments.ts b/app/javascript/flavours/glitch/api_types/media_attachments.ts index fc027ccd2a5d27..6242569b82c98f 100644 --- a/app/javascript/flavours/glitch/api_types/media_attachments.ts +++ b/app/javascript/flavours/glitch/api_types/media_attachments.ts @@ -7,16 +7,85 @@ export type MediaAttachmentType = | 'unknown' | 'audio'; -export interface ApiMediaAttachmentJSON { +export interface BaseApiMediaAttachmentJSON { id: string; type: MediaAttachmentType; url: string; preview_url: string; - remoteUrl: string; - preview_remote_url: string; - text_url: string; - // TODO: how to define this? - meta: unknown; + remote_url?: string; + preview_remote_url?: string; + text_url?: string; description?: string; blurhash: string; } + +export interface ApiImageAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'image'; + meta: { + original: ApiImageAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + }; +} + +export interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'audio'; + meta: { + colors: ApiColorsAttachmentMetaJSON; + original: ApiVideoAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + }; +} + +export interface ApiVideoAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'video'; + meta: { + colors: ApiColorsAttachmentMetaJSON; + original: ApiVideoAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + focus: { + x: number; + y: number; + }; + }; +} + +export interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'gifv'; + meta: { + original: ApiVideoAttachmentMetaJSON; + small: ApiImageAttachmentMetaJSON; + }; +} + +export interface ApiUnknownAttachmentJSON extends BaseApiMediaAttachmentJSON { + type: 'unknown'; + meta: unknown; +} + +export type ApiMediaAttachmentJSON = + | ApiImageAttachmentJSON + | ApiAudioAttachmentJSON + | ApiVideoAttachmentJSON + | ApiGifvAttachmentJSON + | ApiUnknownAttachmentJSON; + +export interface ApiImageAttachmentMetaJSON { + width: number; + height: number; + size: string; + aspect: number; +} + +export interface ApiVideoAttachmentMetaJSON { + width: number; + height: number; + frame_rate: string; + duration: number; + bitrate: number; +} + +export interface ApiColorsAttachmentMetaJSON { + background: string; + foreground: string; + accent: string; +} diff --git a/app/javascript/flavours/glitch/api_types/notification_policies.ts b/app/javascript/flavours/glitch/api_types/notification_policies.ts index 1c3970782cb464..40ca89e78fbdd2 100644 --- a/app/javascript/flavours/glitch/api_types/notification_policies.ts +++ b/app/javascript/flavours/glitch/api_types/notification_policies.ts @@ -8,6 +8,7 @@ export interface NotificationPolicyJSON { for_new_accounts: NotificationPolicyValue; for_private_mentions: NotificationPolicyValue; for_limited_accounts: NotificationPolicyValue; + for_bots: NotificationPolicyValue; summary: { pending_requests_count: number; pending_notifications_count: number; diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts index c4556fa8e5577a..faa0078693cf62 100644 --- a/app/javascript/flavours/glitch/api_types/notifications.ts +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -3,6 +3,7 @@ import type { AccountWarningAction } from 'flavours/glitch/models/notification_group'; import type { ApiAccountJSON } from './accounts'; +import type { ApiCollectionJSON } from './collections'; import type { ApiReportJSON } from './reports'; import type { ApiStatusJSON } from './statuses'; @@ -22,6 +23,8 @@ export const allNotificationTypes: NotificationType[] = [ 'moderation_warning', 'severed_relationships', 'annual_report', + 'added_to_collection', + 'collection_update', ]; export type NotificationWithStatusType = @@ -42,7 +45,9 @@ export type NotificationType = | 'severed_relationships' | 'admin.sign_up' | 'admin.report' - | 'annual_report'; + | 'annual_report' + | 'added_to_collection' + | 'collection_update'; export interface BaseNotificationJSON { id: string; @@ -83,6 +88,26 @@ interface ReportNotificationJSON extends BaseNotificationJSON { report: ApiReportJSON; } +interface AddedToCollectionNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'added_to_collection'; + collection: ApiCollectionJSON | null; +} + +interface AddedToCollectionNotificationJSON extends BaseNotificationJSON { + type: 'added_to_collection'; + collection: ApiCollectionJSON | null; +} + +interface CollectionUpdateNotificationGroupJSON extends BaseNotificationGroupJSON { + type: 'collection_update'; + collection: ApiCollectionJSON | null; +} + +interface CollectionUpdateNotificationJSON extends BaseNotificationJSON { + type: 'collection_update'; + collection: ApiCollectionJSON | null; +} + type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up'; interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON { type: SimpleNotificationTypes; @@ -102,8 +127,7 @@ export interface ApiAccountWarningJSON { appeal: unknown; } -interface ModerationWarningNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface ModerationWarningNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'moderation_warning'; moderation_warning: ApiAccountWarningJSON; } @@ -123,14 +147,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; } @@ -149,7 +171,9 @@ export type ApiNotificationJSON = | ReportNotificationJSON | AccountRelationshipSeveranceNotificationJSON | NotificationWithStatusJSON - | ModerationWarningNotificationJSON; + | ModerationWarningNotificationJSON + | AddedToCollectionNotificationJSON + | CollectionUpdateNotificationJSON; export type ApiNotificationGroupJSON = | SimpleNotificationGroupJSON @@ -157,7 +181,9 @@ export type ApiNotificationGroupJSON = | AccountRelationshipSeveranceNotificationGroupJSON | NotificationGroupWithStatusJSON | ModerationWarningNotificationGroupJSON - | AnnualReportNotificationGroupJSON; + | AnnualReportNotificationGroupJSON + | AddedToCollectionNotificationGroupJSON + | CollectionUpdateNotificationGroupJSON; export interface ApiNotificationGroupsResultJSON { accounts: ApiAccountJSON[]; diff --git a/app/javascript/flavours/glitch/api_types/profile.ts b/app/javascript/flavours/glitch/api_types/profile.ts new file mode 100644 index 00000000000000..acc3b46787d5b1 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/profile.ts @@ -0,0 +1,46 @@ +import type { ApiAccountFieldJSON } from './accounts'; +import type { ApiFeaturedTagJSON } from './tags'; + +export interface ApiProfileJSON { + id: string; + display_name: string; + note: string; + fields: ApiAccountFieldJSON[]; + avatar: string; + avatar_static: string; + avatar_description: string; + header: string; + header_static: string; + header_description: string; + locked: boolean; + bot: boolean; + hide_collections: boolean; + discoverable: boolean; + indexable: boolean; + show_media: boolean; + show_media_replies: boolean; + show_featured: boolean; + attribution_domains: string[]; + featured_tags: ApiFeaturedTagJSON[]; +} + +export type ApiProfileUpdateParams = Partial< + Pick< + ApiProfileJSON, + | 'avatar_description' + | 'header_description' + | 'display_name' + | 'note' + | 'locked' + | 'bot' + | 'hide_collections' + | 'discoverable' + | 'indexable' + | 'show_media' + | 'show_media_replies' + | 'show_featured' + > +> & { + attribution_domains?: string[]; + fields_attributes?: Pick[]; +}; diff --git a/app/javascript/flavours/glitch/api_types/quotes.ts b/app/javascript/flavours/glitch/api_types/quotes.ts index f42a3eb7289024..2a5e8b4e45b3f4 100644 --- a/app/javascript/flavours/glitch/api_types/quotes.ts +++ b/app/javascript/flavours/glitch/api_types/quotes.ts @@ -22,7 +22,7 @@ interface ApiNestedQuoteJSON { interface ApiQuoteAcceptedJSON { state: 'accepted'; quoted_status: Omit & { - quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON; + quote?: ApiNestedQuoteJSON | ApiQuoteEmptyJSON; }; } diff --git a/app/javascript/flavours/glitch/api_types/relationships.ts b/app/javascript/flavours/glitch/api_types/relationships.ts index 9f26a0ce9b333d..aa871d6f792fdf 100644 --- a/app/javascript/flavours/glitch/api_types/relationships.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api_types/search.ts b/app/javascript/flavours/glitch/api_types/search.ts index 795cbb2b41f77f..961dd65699aff9 100644 --- a/app/javascript/flavours/glitch/api_types/search.ts +++ b/app/javascript/flavours/glitch/api_types/search.ts @@ -1,4 +1,5 @@ import type { ApiAccountJSON } from './accounts'; +import type { ApiCollectionJSON } from './collections'; import type { ApiStatusJSON } from './statuses'; import type { ApiHashtagJSON } from './tags'; @@ -8,4 +9,5 @@ export interface ApiSearchResultsJSON { accounts: ApiAccountJSON[]; statuses: ApiStatusJSON[]; hashtags: ApiHashtagJSON[]; + collections: ApiCollectionJSON[]; } diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts index 4ecb34bfe1bb6c..acf192c12d6220 100644 --- a/app/javascript/flavours/glitch/api_types/statuses.ts +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -1,6 +1,7 @@ // See app/serializers/rest/status_serializer.rb import type { ApiAccountJSON } from './accounts'; +import type { ApiCollectionJSON } from './collections'; import type { ApiCustomEmojiJSON } from './custom_emoji'; import type { ApiMediaAttachmentJSON } from './media_attachments'; import type { ApiPollJSON } from './polls'; @@ -41,21 +42,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[]; } @@ -95,11 +95,11 @@ export interface ApiStatusJSON { url: string; replies_count: number; reblogs_count: number; - favorites_count: number; + favourites_count: number; quotes_count: number; edited_at?: string; - favorited?: boolean; + favourited?: boolean; reblogged?: boolean; muted?: boolean; bookmarked?: boolean; @@ -117,6 +117,7 @@ export interface ApiStatusJSON { tags: ApiTagJSON[]; emojis: ApiCustomEmojiJSON[]; + tagged_collections: ApiCollectionJSON[]; card?: ApiPreviewCardJSON; poll?: ApiPollJSON; @@ -139,6 +140,19 @@ export interface ApiStatusSourceJSON { spoiler_text: string; } +export interface ApiStatusTranslationJSON { + detected_source_language: string; + language: string; + provider: string; + contentHtml: string; + spoilerHtml: string; + spoiler_text: string; + poll?: { + id: string; + options: { title: string }[]; + }; +} + export function isStatusVisibility( visibility: string, ): visibility is StatusVisibility { diff --git a/app/javascript/flavours/glitch/api_types/tags.ts b/app/javascript/flavours/glitch/api_types/tags.ts index 3066b4f1f1b82e..52093689bc3470 100644 --- a/app/javascript/flavours/glitch/api_types/tags.ts +++ b/app/javascript/flavours/glitch/api_types/tags.ts @@ -4,11 +4,19 @@ interface ApiHistoryJSON { uses: string; } -export interface ApiHashtagJSON { +interface ApiHashtagBase { id: string; name: string; url: string; +} + +export interface ApiHashtagJSON extends ApiHashtagBase { history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; following?: boolean; featuring?: boolean; } + +export interface ApiFeaturedTagJSON extends ApiHashtagBase { + statuses_count: string; + last_status_at: string | null; +} diff --git a/app/javascript/flavours/glitch/common.ts b/app/javascript/flavours/glitch/common.ts index e621a24e39fe46..33d2b5ad171f40 100644 --- a/app/javascript/flavours/glitch/common.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/__tests__/short_number-test.tsx b/app/javascript/flavours/glitch/components/__tests__/short_number-test.tsx new file mode 100644 index 00000000000000..e221ca1eb8f57e --- /dev/null +++ b/app/javascript/flavours/glitch/components/__tests__/short_number-test.tsx @@ -0,0 +1,80 @@ +import { IntlProvider } from 'react-intl'; + +import { render, screen } from '@testing-library/react'; + +import { ShortNumber } from '../short_number'; + +function renderShortNumber(value: number) { + return render( + + + , + ); +} + +describe('ShortNumber Component', () => { + it('does not abbreviate numbers under 1000', () => { + renderShortNumber(999); + expect(screen.getByText('999')).toBeDefined(); + }); + + it('formats thousands correctly for 1000', () => { + renderShortNumber(1000); + expect(screen.getByText('1K')).toBeDefined(); + }); + + it('truncates decimals for 1051', () => { + renderShortNumber(1051); + expect(screen.getByText('1K')).toBeDefined(); + }); + + it('truncates decimals for 2999', () => { + renderShortNumber(2999); + expect(screen.getByText('2.9K')).toBeDefined(); + }); + + it('truncates decimals for 9999', () => { + renderShortNumber(9999); + expect(screen.getByText('9.9K')).toBeDefined(); + }); + + it('truncates decimals for 10501', () => { + renderShortNumber(10501); + expect(screen.getByText('10K')).toBeDefined(); + }); + + it('truncates decimals for 11000', () => { + renderShortNumber(11000); + expect(screen.getByText('11K')).toBeDefined(); + }); + + it('truncates decimals for 99999', () => { + renderShortNumber(99999); + expect(screen.getByText('99K')).toBeDefined(); + }); + + it('truncates decimals for 100501', () => { + renderShortNumber(100501); + expect(screen.getByText('100K')).toBeDefined(); + }); + + it('truncates decimals for 101000', () => { + renderShortNumber(101000); + expect(screen.getByText('101K')).toBeDefined(); + }); + + it('truncates decimals for 999999', () => { + renderShortNumber(999999); + expect(screen.getByText('999K')).toBeDefined(); + }); + + it('truncates decimals for 2999999', () => { + renderShortNumber(2999999); + expect(screen.getByText('2.9M')).toBeDefined(); + }); + + it('truncates decimals for 9999999', () => { + renderShortNumber(9999999); + expect(screen.getByText('9.9M')).toBeDefined(); + }); +}); diff --git a/app/javascript/flavours/glitch/components/a11y_live_region/a11y_live_region.stories.tsx b/app/javascript/flavours/glitch/components/a11y_live_region/a11y_live_region.stories.tsx new file mode 100644 index 00000000000000..00804d685ba2ad --- /dev/null +++ b/app/javascript/flavours/glitch/components/a11y_live_region/a11y_live_region.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { A11yLiveRegion } from '.'; + +const meta = { + title: 'Components/A11yLiveRegion', + component: A11yLiveRegion, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Polite: Story = { + args: { + children: "This field can't be empty.", + }, +}; + +export const Assertive: Story = { + args: { + ...Polite.args, + role: 'alert', + }, +}; diff --git a/app/javascript/flavours/glitch/components/a11y_live_region/index.tsx b/app/javascript/flavours/glitch/components/a11y_live_region/index.tsx new file mode 100644 index 00000000000000..51fee5e4b93fee --- /dev/null +++ b/app/javascript/flavours/glitch/components/a11y_live_region/index.tsx @@ -0,0 +1,28 @@ +import { polymorphicForwardRef } from '@/types/polymorphic'; + +/** + * A live region is a content region that announces changes of its contents + * to users of assistive technology like screen readers. + * + * Dynamically added warnings, errors, or live status updates should be wrapped + * in a live region to ensure they are not missed when they appear. + * + * **Important:** + * Live regions must be present in the DOM _before_ + * the to-be announced content is rendered into it. + */ + +export const A11yLiveRegion = polymorphicForwardRef<'div'>( + ({ role = 'status', as: Component = 'div', children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); diff --git a/app/javascript/flavours/glitch/components/account.tsx b/app/javascript/flavours/glitch/components/account.tsx deleted file mode 100644 index 826d3c3ebb49b0..00000000000000 --- a/app/javascript/flavours/glitch/components/account.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import { useCallback, useMemo } from 'react'; - -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import { - blockAccount, - unblockAccount, - muteAccount, - unmuteAccount, - followAccountSuccess, - unpinAccount, - pinAccount, -} from 'flavours/glitch/actions/accounts'; -import { showAlertForError } from 'flavours/glitch/actions/alerts'; -import { openModal } from 'flavours/glitch/actions/modal'; -import { initMuteModal } from 'flavours/glitch/actions/mutes'; -import { apiFollowAccount } from 'flavours/glitch/api/accounts'; -import { Avatar } from 'flavours/glitch/components/avatar'; -import { Button } from 'flavours/glitch/components/button'; -import { FollowersCounter } from 'flavours/glitch/components/counters'; -import { DisplayName } from 'flavours/glitch/components/display_name'; -import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; -import { FollowButton } from 'flavours/glitch/components/follow_button'; -import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; -import { ShortNumber } from 'flavours/glitch/components/short_number'; -import { Skeleton } from 'flavours/glitch/components/skeleton'; -import { VerifiedBadge } from 'flavours/glitch/components/verified_badge'; -import { useIdentity } from 'flavours/glitch/identity_context'; -import { me } from 'flavours/glitch/initial_state'; -import type { MenuItem } from 'flavours/glitch/models/dropdown_menu'; -import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; - -import { Permalink } from './permalink'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - cancel_follow_request: { - id: 'account.cancel_follow_request', - defaultMessage: 'Withdraw follow request', - }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - mute_notifications: { - id: 'account.mute_notifications_short', - defaultMessage: 'Mute notifications', - }, - unmute_notifications: { - id: 'account.unmute_notifications_short', - defaultMessage: 'Unmute notifications', - }, - mute: { id: 'account.mute_short', defaultMessage: 'Mute' }, - block: { id: 'account.block_short', defaultMessage: 'Block' }, - more: { id: 'status.more', defaultMessage: 'More' }, - addToLists: { - id: 'account.add_or_remove_from_list', - defaultMessage: 'Add or Remove from lists', - }, - openOriginalPage: { - id: 'account.open_original_page', - defaultMessage: 'Open original page', - }, -}); - -interface AccountProps { - size?: number; - id: string; - hidden?: boolean; - minimal?: boolean; - defaultAction?: 'block' | 'mute'; - withBio?: boolean; - withMenu?: boolean; -} - -export const Account: React.FC = ({ - id, - size = 46, - hidden, - minimal, - defaultAction, - withBio, - withMenu = true, -}) => { - const intl = useIntl(); - const { signedIn } = useIdentity(); - const account = useAppSelector((state) => state.accounts.get(id)); - const relationship = useAppSelector((state) => state.relationships.get(id)); - const dispatch = useAppDispatch(); - const accountUrl = account?.url; - const isRemote = account?.acct !== account?.username; - - const handleBlock = useCallback(() => { - if (relationship?.blocking) { - dispatch(unblockAccount(id)); - } else { - dispatch(blockAccount(id)); - } - }, [dispatch, id, relationship]); - - const handleMute = useCallback(() => { - if (relationship?.muting) { - dispatch(unmuteAccount(id)); - } else { - dispatch(initMuteModal(account)); - } - }, [dispatch, id, account, relationship]); - - const menu = useMemo(() => { - let arr: MenuItem[] = []; - - if (defaultAction === 'mute') { - const handleMuteNotifications = () => { - dispatch(muteAccount(id, true)); - }; - - const handleUnmuteNotifications = () => { - dispatch(muteAccount(id, false)); - }; - - arr = [ - { - text: intl.formatMessage( - relationship?.muting_notifications - ? messages.unmute_notifications - : messages.mute_notifications, - ), - action: relationship?.muting_notifications - ? handleUnmuteNotifications - : handleMuteNotifications, - }, - ]; - } else if (defaultAction !== 'block') { - if (isRemote && accountUrl) { - arr.push({ - text: intl.formatMessage(messages.openOriginalPage), - href: accountUrl, - }); - } - - if (signedIn) { - const handleAddToLists = () => { - const openAddToListModal = () => { - dispatch( - openModal({ - modalType: 'LIST_ADDER', - modalProps: { - accountId: id, - }, - }), - ); - }; - if (relationship?.following || relationship?.requested || id === me) { - openAddToListModal(); - } else { - dispatch( - openModal({ - modalType: 'CONFIRM_FOLLOW_TO_LIST', - modalProps: { - accountId: id, - onConfirm: () => { - apiFollowAccount(id) - .then((relationship) => { - dispatch( - followAccountSuccess({ - relationship, - alreadyFollowing: false, - }), - ); - openAddToListModal(); - }) - .catch((err: unknown) => { - dispatch(showAlertForError(err)); - }); - }, - }, - }), - ); - } - }; - - arr.push({ - text: intl.formatMessage(messages.addToLists), - action: handleAddToLists, - }); - - if (id !== me && (relationship?.following || relationship?.requested)) { - const handleEndorseToggle = () => { - if (relationship.endorsed) { - dispatch(unpinAccount(id)); - } else { - dispatch(pinAccount(id)); - } - }; - arr.push({ - text: intl.formatMessage( - // Defined in features/account_timeline/components/account_header.tsx - relationship.endorsed - ? { id: 'account.unendorse' } - : { id: 'account.endorse' }, - ), - action: handleEndorseToggle, - }); - } - } - } - - return arr; - }, [ - dispatch, - intl, - id, - accountUrl, - relationship, - defaultAction, - isRemote, - signedIn, - ]); - - if (hidden) { - return ( - <> - {account?.display_name} - {account?.username} - - ); - } - - let button: React.ReactNode; - let dropdown: React.ReactNode; - - if (menu.length > 0 && withMenu) { - dropdown = ( - - ); - } - - if (defaultAction === 'block') { - button = ( - + + + + + ); +}; + +const MovedNote: React.FC<{ + account: Account; + targetAccountId: string; +}> = ({ account: from, targetAccountId }) => { + const to = useAppSelector((state) => state.accounts.get(targetAccountId)); + + return ( + <> + + , + }} + /> + + +

+ +
+ +
+ +
+ + + + +
+ + ); +}; + +const MessageText: React.FC<{ children: ReactElement }> = ({ children }) => ( +
{children}
+); diff --git a/app/javascript/flavours/glitch/components/account_header/buttons.tsx b/app/javascript/flavours/glitch/components/account_header/buttons.tsx new file mode 100644 index 00000000000000..77003e71e31f66 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/buttons.tsx @@ -0,0 +1,136 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { followAccount } from '@/flavours/glitch/actions/accounts'; +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { getAccountHidden } from '@/flavours/glitch/selectors/accounts'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; +import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; +import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react'; +import ShareIcon from '@/material-icons/400-24px/share.svg?react'; + +import { CopyIconButton } from '../copy_button'; +import { FollowButton } from '../follow_button'; +import { IconButton } from '../icon_button'; + +import { AccountMenu } from './menu'; +import classes from './styles.module.scss'; + +const messages = defineMessages({ + enableNotifications: { + id: 'account.enable_notifications', + defaultMessage: 'Notify me when @{name} posts', + }, + disableNotifications: { + id: 'account.disable_notifications', + defaultMessage: 'Stop notifying me when @{name} posts', + }, + share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" }, + copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' }, +}); + +interface AccountButtonsProps { + accountId: string; + className?: string; + noShare?: boolean; + forceMenu?: boolean; +} + +export const AccountButtons: FC = ({ + accountId, + className, + noShare, + forceMenu, +}) => { + const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); + const me = useAppSelector((state) => state.meta.get('me') as string); + + return ( +
+ {!hidden && ( + + )} + {(accountId !== me || forceMenu) && } +
+ ); +}; + +const AccountButtonsOther: FC< + Pick +> = ({ accountId, noShare }) => { + const intl = useIntl(); + const account = useAccount(accountId); + const relationship = useAppSelector((state) => + state.relationships.get(accountId), + ); + + const dispatch = useAppDispatch(); + const handleNotifyToggle = useCallback(() => { + if (account) { + dispatch(followAccount(account.id, { notify: !relationship?.notifying })); + } + }, [dispatch, account, relationship]); + const accountUrl = account?.url; + const handleShare = useCallback(() => { + if (accountUrl) { + void navigator.share({ + url: accountUrl, + }); + } + }, [accountUrl]); + + if (!account) { + return null; + } + + const isMovedAndUnfollowedAccount = account.moved && !relationship?.following; + const isFollowing = relationship?.requested || relationship?.following; + + return ( + <> + {!isMovedAndUnfollowedAccount && ( + + )} + {isFollowing && ( + + )} + {!noShare && + ('share' in navigator ? ( + + ) : ( + + ))} + + ); +}; diff --git a/app/javascript/flavours/glitch/components/account_header/fields.tsx b/app/javascript/flavours/glitch/components/account_header/fields.tsx new file mode 100644 index 00000000000000..ab441ba71c4b31 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/fields.tsx @@ -0,0 +1,379 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { FC } from 'react'; + +import { defineMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { openModal } from '@/flavours/glitch/actions/modal'; +import { useFieldHtml } from '@/flavours/glitch/features/account_timeline/hooks/useFieldHtml'; +import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize'; +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useResizeObserver } from '@/flavours/glitch/hooks/useObserver'; +import type { AccountFieldShape } from '@/flavours/glitch/models/account'; +import { useAppDispatch } from '@/flavours/glitch/store'; +import IconVerified from '@/images/icons/icon_verified.svg?react'; +import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react'; + +import { CustomEmojiProvider } from '../emoji/context'; +import type { EmojiHTMLProps } from '../emoji/html'; +import { EmojiHTML } from '../emoji/html'; +import { Icon } from '../icon'; +import { IconButton } from '../icon_button'; +import { MiniCard } from '../mini_card'; +import { useElementHandledLink } from '../status/handled_link'; + +import classes from './styles.module.scss'; + +const verifyMessage = defineMessage({ + id: 'account.link_verified_on', + defaultMessage: 'Ownership of this link was checked on {date}', +}); +const dateFormatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; + +export interface AccountField extends AccountFieldShape { + nameHasEmojis: boolean; + value_plain: string; + valueHasEmojis: boolean; +} + +export const AccountHeaderFields: FC<{ accountId: string }> = ({ + accountId, +}) => { + const account = useAccount(accountId); + + const emojis = useMemo( + () => cleanExtraEmojis(account?.emojis), + [account?.emojis], + ); + const accountFields = account?.fields; + const fields: AccountField[] = useMemo(() => { + const fields = accountFields?.toJS(); + if (!fields) { + return []; + } + + if (!emojis) { + return fields.map((field) => ({ + ...field, + nameHasEmojis: false, + value_plain: field.value_plain ?? '', + valueHasEmojis: false, + })); + } + + const shortcodes = Object.keys(emojis); + return fields.map((field) => ({ + ...field, + nameHasEmojis: shortcodes.some((code) => + field.name.includes(`:${code}:`), + ), + value_plain: field.value_plain ?? '', + valueHasEmojis: shortcodes.some((code) => + field.value_plain?.includes(`:${code}:`), + ), + })); + }, [accountFields, emojis]); + + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: account?.id, + }); + + const { wrapperRef } = useColumnWrap(); + + if (fields.length === 0) { + return null; + } + + return ( + +
+ {fields.map((field, key) => ( + + ))} +
+
+ ); +}; + +const FieldCard: FC<{ + htmlHandlers: ReturnType; + field: AccountField; +}> = ({ htmlHandlers, field }) => { + const intl = useIntl(); + const { + name_emojified, + nameHasEmojis, + value_emojified, + valueHasEmojis, + verified_at, + } = field; + + const { wrapperRef, isLabelOverflowing, isValueOverflowing } = + useFieldOverflow(); + + const dispatch = useAppDispatch(); + const handleOverflowClick = useCallback(() => { + dispatch( + openModal({ + modalType: 'ACCOUNT_FIELD_OVERFLOW', + modalProps: { field }, + }), + ); + }, [dispatch, field]); + + return ( + + } + value={ + + } + ref={wrapperRef} + > + {verified_at && ( + + + + )} + + ); +}; + +type FieldHTMLProps = { + text: string; + textHasCustomEmoji: boolean; + isOverflowing?: boolean; + onOverflowClick?: () => void; +} & Omit; + +const FieldHTML: FC = ({ + className, + text, + textHasCustomEmoji, + isOverflowing, + onOverflowClick, + onElement, + ...props +}) => { + const intl = useIntl(); + const handleElement = useFieldHtml(textHasCustomEmoji, onElement); + + const html = ( + + ); + if (!isOverflowing) { + return html; + } + + return ( + <> + {html} + + + ); +}; + +function useColumnWrap() { + const listRef = useRef(null); + + const handleRecalculate = useCallback(() => { + const listEle = listRef.current; + if (!listEle) { + return; + } + + // Calculate dimensions from styles and element size to determine column spans. + const styles = getComputedStyle(listEle); + const gap = parseFloat(styles.columnGap || styles.gap || '0'); + const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2; + const listWidth = listEle.offsetWidth; + const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount; + const halfColSpan = columnCount / 2; + + // Matrix to hold the grid layout. + const itemGrid: { ele: HTMLElement; span: number }[][] = []; + + // First, determine the column span for each item and populate the grid matrix. + let currentRow = 0; + for (const child of listEle.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + + // This uses a data attribute to detect which elements to measure that overflow. + const contents = child.querySelectorAll('[data-contents]'); + + const childStyles = getComputedStyle(child); + const padding = + parseFloat(childStyles.paddingLeft) + + parseFloat(childStyles.paddingRight); + + const contentWidth = + Math.max( + ...Array.from(contents).map((content) => content.scrollWidth), + ) + padding; + + const contentSpan = Math.ceil(contentWidth / colWidth); + const maxColSpan = Math.min(contentSpan, columnCount); + + const curRow = itemGrid[currentRow] ?? []; + const availableCols = + columnCount - curRow.reduce((carry, curr) => carry + curr.span, 0); + // Move to next row if current item doesn't fit. + if (maxColSpan > availableCols) { + currentRow++; + } + + itemGrid[currentRow] = (itemGrid[currentRow] ?? []).concat({ + ele: child, + span: maxColSpan, + }); + } + + // Next, iterate through the grid matrix and set the column spans and row breaks. + for (const row of itemGrid) { + let remainingRowSpan = columnCount; + for (let i = 0; i < row.length; i++) { + const item = row[i]; + if (!item) { + break; + } + + const { ele, span } = item; + + if (i < row.length - 1) { + ele.dataset.cols = span.toString(); + remainingRowSpan -= span; + } else if ( + row.length > 1 && + row.length === halfColSpan && + span === 1 && + remainingRowSpan > 1 + ) { + // Special case for 2 items in a row where both items only need 1 column. + ele.dataset.cols = halfColSpan.toString(); + if (row[0]) { + row[0].ele.dataset.cols = halfColSpan.toString(); + } + } else { + // Last item in the row takes up remaining space to fill the row. + ele.dataset.cols = remainingRowSpan.toString(); + break; + } + } + } + }, []); + + const observer = useResizeObserver(handleRecalculate); + + const wrapperRefCallback = useCallback( + (element: HTMLDListElement | null) => { + if (element) { + listRef.current = element; + observer.observe(element); + handleRecalculate(); + } + }, + [handleRecalculate, observer], + ); + + return { wrapperRef: wrapperRefCallback }; +} + +function useFieldOverflow() { + const [isLabelOverflowing, setIsLabelOverflowing] = useState(false); + const [isValueOverflowing, setIsValueOverflowing] = useState(false); + + const wrapperRef = useRef(null); + + const handleRecalculate = useCallback(() => { + const wrapperEle = wrapperRef.current; + if (!wrapperEle) return; + + const wrapperStyles = getComputedStyle(wrapperEle); + const nonContentWidth = + parseFloat(wrapperStyles.paddingLeft) + + parseFloat(wrapperStyles.paddingRight) + + parseFloat(wrapperStyles.borderLeftWidth) + + parseFloat(wrapperStyles.borderRightWidth); + const availableContentWidth = wrapperEle.offsetWidth - nonContentWidth; + + const label = wrapperEle.querySelector( + 'dt > [data-contents]', + ); + const value = wrapperEle.querySelector( + 'dd > [data-contents]', + ); + + setIsLabelOverflowing( + label ? label.scrollWidth > availableContentWidth : false, + ); + setIsValueOverflowing( + value ? value.scrollWidth > availableContentWidth : false, + ); + }, []); + + const observer = useResizeObserver(handleRecalculate); + + const wrapperRefCallback = useCallback( + (element: HTMLElement | null) => { + if (element) { + wrapperRef.current = element; + observer.observe(element); + } + }, + [observer], + ); + + return { + isLabelOverflowing, + isValueOverflowing, + wrapperRef: wrapperRefCallback, + }; +} diff --git a/app/javascript/flavours/glitch/components/account_header/index.tsx b/app/javascript/flavours/glitch/components/account_header/index.tsx new file mode 100644 index 00000000000000..8c82e2cb404dbb --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/index.tsx @@ -0,0 +1,185 @@ +import { useCallback } from 'react'; + +import classNames from 'classnames'; + +import { Helmet } from '@unhead/react/helmet'; + +import { openModal } from '@/flavours/glitch/actions/modal'; +import { useLayout } from '@/flavours/glitch/hooks/useLayout'; +import { useVisibility } from '@/flavours/glitch/hooks/useVisibility'; +import { + autoPlayGif, + me, + domain as localDomain, +} from '@/flavours/glitch/initial_state'; +import type { Account } from '@/flavours/glitch/models/account'; +import { getAccountHidden } from '@/flavours/glitch/selectors/accounts'; +import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store'; + +import { AccountBio } from '../account_bio'; +import { Avatar } from '../avatar'; +import { AnimateEmojiProvider } from '../emoji/context'; +import { FamiliarFollowers } from '../familiar_followers'; + +import { AccountBanners } from './banners'; +import { AccountButtons } from './buttons'; +import { AccountHeaderFields } from './fields'; +import { AccountName } from './name'; +import { AccountNote } from './note'; +import { AccountNumberFields } from './number_fields'; +import classes from './styles.module.scss'; +import { AccountSubscriptionForm } from './subscription_form'; +import { AccountTabs } from './tabs'; + +const titleFromAccount = (account: Account) => { + const displayName = account.display_name; + const acct = + account.acct === account.username + ? `${account.username}@${localDomain}` + : account.acct; + const prefix = + displayName.trim().length === 0 ? account.username : displayName; + + return `${prefix} (@${acct})`; +}; + +export const AccountHeader: React.FC<{ + accountId: string; + hideTabs?: boolean; +}> = ({ accountId, hideTabs }) => { + const dispatch = useAppDispatch(); + const account = useAppSelector((state) => state.accounts.get(accountId)); + const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); + + const handleOpenAvatar = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0 || e.ctrlKey || e.metaKey) { + return; + } + + e.preventDefault(); + + if (!account) { + return; + } + + dispatch( + openModal({ + modalType: 'IMAGE', + modalProps: { + src: account.avatar, + alt: account.avatar_description, + }, + }), + ); + }, + [dispatch, account], + ); + + const { layout } = useLayout(); + const { observedRef, isIntersecting } = useVisibility({ + observerOptions: { + rootMargin: layout === 'mobile' ? '0px 0px -55px 0px' : '', // Height of bottom nav bar. + }, + }); + + if (!account) { + return null; + } + + const suspendedOrHidden = hidden || account.suspended; + const isLocal = !account.acct.includes('@'); + const isMe = me && account.id === me; + + return ( +
+ + + +
+ {!suspendedOrHidden && ( + {account.header_description} + )} +
+ +
+
+ + + +
+ +
+ + +
+ + + + {!isMe && !suspendedOrHidden && ( + + )} + + {!suspendedOrHidden && ( +
+ {me && account.id !== me && } + + + + + + {!me && account.email_subscriptions && ( + + )} +
+ )} + + +
+
+ + {!hideTabs && !hidden && } +
+ + + {titleFromAccount(account)} + + + +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/account_header/menu.tsx b/app/javascript/flavours/glitch/components/account_header/menu.tsx new file mode 100644 index 00000000000000..4f113527eec0e5 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/menu.tsx @@ -0,0 +1,543 @@ +import { useMemo } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { + followAccount, + pinAccount, + unblockAccount, + unmuteAccount, + unpinAccount, +} from '@/flavours/glitch/actions/accounts'; +import { removeAccountFromFollowers } from '@/flavours/glitch/actions/accounts_typed'; +import { showAlert } from '@/flavours/glitch/actions/alerts'; +import { initBlockModal } from '@/flavours/glitch/actions/blocks'; +import { + directCompose, + mentionCompose, +} from '@/flavours/glitch/actions/compose'; +import { + initDomainBlockModal, + unblockDomain, +} from '@/flavours/glitch/actions/domain_blocks'; +import { openModal } from '@/flavours/glitch/actions/modal'; +import { initMuteModal } from '@/flavours/glitch/actions/mutes'; +import { initReport } from '@/flavours/glitch/actions/reports'; +import { + canAccountBeAdded, + canAccountBeAddedByFollowers, +} from '@/flavours/glitch/features/collections/utils'; +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useIdentity } from '@/flavours/glitch/identity_context'; +import type { Account } from '@/flavours/glitch/models/account'; +import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu'; +import type { Relationship } from '@/flavours/glitch/models/relationship'; +import { + PERMISSION_MANAGE_FEDERATION, + PERMISSION_MANAGE_USERS, +} from '@/flavours/glitch/permissions'; +import type { AppDispatch } from '@/flavours/glitch/store'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; +import BlockIcon from '@/material-icons/400-24px/block.svg?react'; +import LinkIcon from '@/material-icons/400-24px/link_2.svg?react'; +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react'; +import ReportIcon from '@/material-icons/400-24px/report.svg?react'; +import ShareIcon from '@/material-icons/400-24px/share.svg?react'; + +import { Dropdown } from '../dropdown_menu'; + +import classes from './styles.module.scss'; + +export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { + const intl = useIntl(); + const { signedIn, permissions } = useIdentity(); + + const account = useAccount(accountId); + const relationship = useAppSelector((state) => + state.relationships.get(accountId), + ); + const currentAccountId = useAppSelector( + (state) => state.meta.get('me') as string, + ); + const isMe = currentAccountId === accountId; + + const dispatch = useAppDispatch(); + const menuItems = useMemo(() => { + if (!account) { + return []; + } + + return getMenuItems({ + account, + signedIn: !isMe && signedIn, + permissions, + intl, + relationship, + dispatch, + }); + }, [account, signedIn, isMe, permissions, intl, relationship, dispatch]); + return ( + + ); +}; + +interface MenuItemsParams { + account: Account; + signedIn: boolean; + permissions: number; + intl: ReturnType; + relationship?: Relationship; + dispatch: AppDispatch; +} + +const messages = defineMessages({ + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, + direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + blockDomain: { + id: 'account.block_domain', + defaultMessage: 'Block domain {domain}', + }, + unblockDomain: { + id: 'account.unblock_domain', + defaultMessage: 'Unblock domain {domain}', + }, + hideReblogs: { + id: 'account.hide_reblogs', + defaultMessage: 'Hide boosts from @{name}', + }, + showReblogs: { + id: 'account.show_reblogs', + defaultMessage: 'Show boosts from @{name}', + }, + addNote: { + id: 'account.add_note', + defaultMessage: 'Add a personal note', + }, + editNote: { + id: 'account.edit_note', + defaultMessage: 'Edit personal note', + }, + endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, + unendorse: { + id: 'account.unendorse', + defaultMessage: "Don't feature on profile", + }, + add_or_remove_from_list: { + id: 'account.add_or_remove_from_list', + defaultMessage: 'Add or Remove from lists', + }, + admin_account: { + id: 'status.admin_account', + defaultMessage: 'Open moderation interface for @{name}', + }, + admin_domain: { + id: 'status.admin_domain', + defaultMessage: 'Open moderation interface for {domain}', + }, + languages: { + id: 'account.languages', + defaultMessage: 'Change subscribed languages', + }, + openOriginalPage: { + id: 'account.open_original_page', + defaultMessage: 'Open original page', + }, + removeFromFollowers: { + id: 'account.remove_from_followers', + defaultMessage: 'Remove {name} from followers', + }, + confirmRemoveFromFollowersTitle: { + id: 'confirmations.remove_from_followers.title', + defaultMessage: 'Remove follower?', + }, + confirmRemoveFromFollowersMessage: { + id: 'confirmations.remove_from_followers.message', + defaultMessage: + '{name} will stop following you. Are you sure you want to proceed?', + }, + confirmRemoveFromFollowersButton: { + id: 'confirmations.remove_from_followers.confirm', + defaultMessage: 'Remove follower', + }, +}); + +const redesignMessages = defineMessages({ + share: { id: 'account.menu.share', defaultMessage: 'Share…' }, + copy: { id: 'account.menu.copy', defaultMessage: 'Copy link' }, + copied: { + id: 'account.menu.copied', + defaultMessage: 'Copied account link to clipboard', + }, + mention: { id: 'account.menu.mention', defaultMessage: 'Mention' }, + noteDescription: { + id: 'account.menu.note.description', + defaultMessage: 'Visible only to you', + }, + direct: { + id: 'account.menu.direct', + defaultMessage: 'Privately mention', + }, + mute: { id: 'account.menu.mute', defaultMessage: 'Mute account' }, + unmute: { + id: 'account.menu.unmute', + defaultMessage: 'Unmute account', + }, + block: { id: 'account.menu.block', defaultMessage: 'Block account' }, + unblock: { + id: 'account.menu.unblock', + defaultMessage: 'Unblock account', + }, + domainBlock: { + id: 'account.menu.block_domain', + defaultMessage: 'Block {domain}', + }, + domainUnblock: { + id: 'account.menu.unblock_domain', + defaultMessage: 'Unblock {domain}', + }, + report: { id: 'account.menu.report', defaultMessage: 'Report account' }, + hideReblogs: { + id: 'account.menu.hide_reblogs', + defaultMessage: 'Hide boosts in timeline', + }, + showReblogs: { + id: 'account.menu.show_reblogs', + defaultMessage: 'Show boosts in timeline', + }, + addToList: { + id: 'account.menu.add_to_list', + defaultMessage: 'Add to list…', + }, + addToCollection: { + id: 'account.menu.add_to_collection', + defaultMessage: 'Add to collection…', + }, + openOriginalPage: { + id: 'account.menu.open_original_page', + defaultMessage: 'View on {domain}', + }, + removeFollower: { + id: 'account.menu.remove_follower', + defaultMessage: 'Remove follower', + }, +}); + +function getMenuItems({ + account, + signedIn, + permissions, + intl, + relationship, + dispatch, +}: MenuItemsParams): MenuItem[] { + const items: MenuItem[] = []; + const isRemote = account.acct !== account.username; + const remoteDomain = isRemote ? account.acct.split('@')[1] : null; + + // Share and copy link options + if (account.url) { + if ('share' in navigator) { + items.push({ + text: intl.formatMessage(redesignMessages.share), + action: () => { + void navigator.share({ + url: account.url, + }); + }, + icon: ShareIcon, + }); + } + items.push({ + text: intl.formatMessage(redesignMessages.copy), + action: () => { + void navigator.clipboard.writeText(account.url); + dispatch(showAlert({ message: redesignMessages.copied })); + }, + icon: LinkIcon, + }); + } + + // Open on remote page. + if (isRemote) { + items.push({ + text: intl.formatMessage(redesignMessages.openOriginalPage, { + domain: remoteDomain, + }), + href: account.url, + }); + } + + // Mention and direct message options + if (signedIn && !account.suspended) { + items.push( + null, + { + text: intl.formatMessage(redesignMessages.mention), + action: () => { + dispatch(mentionCompose(account)); + }, + }, + + { + text: intl.formatMessage(redesignMessages.direct), + action: () => { + dispatch(directCompose(account)); + }, + }, + null, + ); + } + + if (!signedIn) { + return items; + } + + // Add to list + if (relationship?.following) { + items.push({ + text: intl.formatMessage(redesignMessages.addToList), + action: () => { + dispatch( + openModal({ + modalType: 'LIST_ADDER', + modalProps: { + accountId: account.id, + }, + }), + ); + }, + }); + } + + // Add to collection + if ( + canAccountBeAdded(account) || + (canAccountBeAddedByFollowers(account) && relationship?.following) + ) { + items.push({ + text: intl.formatMessage(redesignMessages.addToCollection), + action: () => { + dispatch( + openModal({ + modalType: 'COLLECTION_ADDER', + modalProps: { + accountId: account.id, + }, + }), + ); + }, + }); + } + + // Feature on profile + if (relationship?.following) { + items.push({ + text: intl.formatMessage( + relationship.endorsed ? messages.unendorse : messages.endorse, + ), + action: () => { + if (relationship.endorsed) { + dispatch(unpinAccount(account.id)); + } else { + dispatch(pinAccount(account.id)); + } + }, + }); + } + + items.push( + { + text: intl.formatMessage( + relationship?.note ? messages.editNote : messages.addNote, + ), + description: intl.formatMessage(redesignMessages.noteDescription), + action: () => { + dispatch( + openModal({ + modalType: 'ACCOUNT_NOTE', + modalProps: { + accountId: account.id, + }, + }), + ); + }, + }, + null, + ); + + // Timeline options + if (relationship?.following && !relationship.muting) { + items.push( + { + text: intl.formatMessage( + relationship.showing_reblogs + ? redesignMessages.hideReblogs + : redesignMessages.showReblogs, + ), + action: () => { + dispatch( + followAccount(account.id, { + reblogs: !relationship.showing_reblogs, + }), + ); + }, + }, + { + text: intl.formatMessage(messages.languages), + action: () => { + dispatch( + openModal({ + modalType: 'SUBSCRIBED_LANGUAGES', + modalProps: { + accountId: account.id, + }, + }), + ); + }, + }, + ); + } + + items.push( + { + text: intl.formatMessage( + relationship?.muting ? redesignMessages.unmute : redesignMessages.mute, + ), + action: () => { + if (relationship?.muting) { + dispatch(unmuteAccount(account.id)); + } else { + dispatch(initMuteModal(account)); + } + }, + }, + null, + ); + + if (relationship?.followed_by) { + items.push({ + text: intl.formatMessage(redesignMessages.removeFollower), + action: () => { + dispatch( + openModal({ + modalType: 'CONFIRM', + modalProps: { + title: intl.formatMessage( + messages.confirmRemoveFromFollowersTitle, + ), + message: intl.formatMessage( + messages.confirmRemoveFromFollowersMessage, + { name: {account.acct} }, + ), + confirm: intl.formatMessage( + messages.confirmRemoveFromFollowersButton, + ), + onConfirm: () => { + void dispatch( + removeAccountFromFollowers({ accountId: account.id }), + ); + }, + }, + }), + ); + }, + dangerous: true, + icon: PersonRemoveIcon, + }); + } + + items.push({ + text: intl.formatMessage( + relationship?.blocking + ? redesignMessages.unblock + : redesignMessages.block, + ), + action: () => { + if (relationship?.blocking) { + dispatch(unblockAccount(account.id)); + } else { + dispatch(initBlockModal(account)); + } + }, + dangerous: true, + icon: BlockIcon, + }); + + if (!account.suspended) { + items.push({ + text: intl.formatMessage(redesignMessages.report), + action: () => { + dispatch(initReport(account)); + }, + dangerous: true, + icon: ReportIcon, + }); + } + + if (remoteDomain) { + items.push(null, { + text: intl.formatMessage( + relationship?.domain_blocking + ? redesignMessages.domainUnblock + : redesignMessages.domainBlock, + { + domain: remoteDomain, + }, + ), + action: () => { + if (relationship?.domain_blocking) { + dispatch(unblockDomain(remoteDomain)); + } else { + dispatch(initDomainBlockModal(account)); + } + }, + dangerous: true, + icon: BlockIcon, + iconId: 'domain-block', + }); + } + + if ( + (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || + (isRemote && + (permissions & PERMISSION_MANAGE_FEDERATION) === + PERMISSION_MANAGE_FEDERATION) + ) { + items.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { + items.push({ + text: intl.formatMessage(messages.admin_account, { + name: account.username, + }), + href: `/admin/accounts/${account.id}`, + }); + } + if ( + remoteDomain && + (permissions & PERMISSION_MANAGE_FEDERATION) === + PERMISSION_MANAGE_FEDERATION + ) { + items.push({ + text: intl.formatMessage(messages.admin_domain, { + domain: remoteDomain, + }), + href: `/admin/instances/${remoteDomain}`, + }); + } + } + + return items; +} diff --git a/app/javascript/flavours/glitch/components/account_header/name.tsx b/app/javascript/flavours/glitch/components/account_header/name.tsx new file mode 100644 index 00000000000000..aca296c311fac3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/name.tsx @@ -0,0 +1,198 @@ +import { useCallback, useId, useRef, useState } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/esm/Overlay'; + +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useRelationship } from '@/flavours/glitch/hooks/useRelationship'; +import { useAppSelector } from '@/flavours/glitch/store'; +import AtIcon from '@/material-icons/400-24px/alternate_email.svg?react'; +import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import HelpIcon from '@/material-icons/400-24px/help.svg?react'; +import DomainIcon from '@/material-icons/400-24px/language.svg?react'; + +import { FollowsYouBadge } from '../badge'; +import { CopyButton } from '../copy_button'; +import { DisplayName } from '../display_name'; +import { Icon } from '../icon'; +import { NavigationFocusTarget } from '../navigation_focus_target'; + +import { AccountBadges } from './badges'; +import classes from './styles.module.scss'; + +const messages = defineMessages({ + lockedInfo: { + id: 'account.locked_info', + defaultMessage: + 'This account privacy status is set to locked. The owner manually reviews who can follow them.', + }, + nameInfo: { + id: 'account.name_info', + defaultMessage: 'What does this mean?', + }, + copied: { + id: 'copy_icon_button.copied', + defaultMessage: 'Copied to clipboard', + }, +}); + +export const AccountName: FC<{ accountId: string }> = ({ accountId }) => { + const account = useAccount(accountId); + const me = useAppSelector((state) => state.meta.get('me') as string); + const localDomain = useAppSelector( + (state) => state.meta.get('domain') as string, + ); + const relationship = useRelationship(accountId); + + if (!account) { + return null; + } + + const [username = '', domain = localDomain] = account.acct.split('@'); + + return ( +
+
+ + + + {relationship?.followed_by && } +
+ + + + +
+ ); +}; + +const AccountNameHelp: FC<{ + username: string; + domain: string; + isSelf: boolean; +}> = ({ username, domain, isSelf }) => { + const accessibilityId = useId(); + const intl = useIntl(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const handleClick = useCallback(() => { + setOpen((prev) => !prev); + }, []); + + const handle = `@${username}@${domain}`; + + return ( + <> + + + + {({ props }) => ( +
+ +
    +
  1. + + {isSelf ? ( + {username} }} + tagName='p' + /> + ) : ( + {username} }} + tagName='p' + /> + )} +
  2. +
  3. + + {isSelf ? ( + {domain} }} + tagName='p' + /> + ) : ( + {domain} }} + tagName='p' + /> + )} +
  4. +
+ + + + {(wasCopied) => ( + <> + + {!wasCopied && ( + + )} + {wasCopied && ( + + )} + + )} + +
+ )} +
+ + ); +}; diff --git a/app/javascript/flavours/glitch/components/account_header/note.tsx b/app/javascript/flavours/glitch/components/account_header/note.tsx new file mode 100644 index 00000000000000..ce68b5492a6c56 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/note.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { fetchRelationships } from '@/flavours/glitch/actions/accounts'; +import { openModal } from '@/flavours/glitch/actions/modal'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; +import EditIcon from '@/material-icons/400-24px/edit_square.svg?react'; + +import { Callout } from '../callout'; +import { IconButton } from '../icon_button'; + +import classes from './styles.module.scss'; + +const messages = defineMessages({ + title: { + id: 'account.note.title', + defaultMessage: 'Personal note (visible only to you)', + }, + editButton: { + id: 'account.note.edit_button', + defaultMessage: 'Edit', + }, +}); + +export const AccountNote: FC<{ accountId: string }> = ({ accountId }) => { + const intl = useIntl(); + const relationship = useAppSelector((state) => + state.relationships.get(accountId), + ); + const dispatch = useAppDispatch(); + useEffect(() => { + if (!relationship) { + dispatch(fetchRelationships([accountId])); + } + }, [accountId, dispatch, relationship]); + + const handleEdit = useCallback(() => { + dispatch( + openModal({ + modalType: 'ACCOUNT_NOTE', + modalProps: { accountId }, + }), + ); + }, [accountId, dispatch]); + + if (!relationship?.note) { + return null; + } + + return ( + + } + > +
{relationship.note}
+
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/account_header/number_fields.tsx b/app/javascript/flavours/glitch/components/account_header/number_fields.tsx new file mode 100644 index 00000000000000..a9ccda6eaecc46 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/number_fields.tsx @@ -0,0 +1,86 @@ +import { useCallback, useMemo } from 'react'; +import type { FC } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import { openModal } from '@/flavours/glitch/actions/modal'; +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useAppDispatch } from '@/flavours/glitch/store'; + +import { FormattedDateWrapper } from '../formatted_date'; +import { NumberFields, NumberFieldsItem } from '../number_fields'; +import { ShortNumber } from '../short_number'; + +import classes from './styles.module.scss'; + +export const AccountNumberFields: FC<{ accountId: string }> = ({ + accountId, +}) => { + const intl = useIntl(); + const account = useAccount(accountId); + const createdThisYear = useMemo( + () => account?.created_at.includes(new Date().getFullYear().toString()), + [account?.created_at], + ); + + const dispatch = useAppDispatch(); + const showJoinModal = useCallback(() => { + dispatch( + openModal({ modalType: 'ACCOUNT_JOIN_DATE', modalProps: { accountId } }), + ); + }, [accountId, dispatch]); + + if (!account) { + return null; + } + + return ( + + + } + hint={intl.formatNumber(account.followers_count)} + link={`/@${account.acct}/followers`} + > + + + + + } + hint={intl.formatNumber(account.following_count)} + link={`/@${account.acct}/following`} + > + + + + } + hint={intl.formatNumber(account.statuses_count)} + > + + + + + } + hint={intl.formatDate(account.created_at)} + > + + + + ); +}; diff --git a/app/javascript/flavours/glitch/components/account_header/styles.module.scss b/app/javascript/flavours/glitch/components/account_header/styles.module.scss new file mode 100644 index 00000000000000..56d57bc81c6551 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/styles.module.scss @@ -0,0 +1,574 @@ +.moved { + opacity: 0.5; +} + +// Account header +.header { + height: 120px; + overflow: hidden; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border-primary); + + img { + object-fit: cover; + display: block; + width: 100%; + height: 100%; + margin: 0; + } + + @container (width >= 500px) { + height: 160px; + } + + .moved & { + filter: grayscale(100%); + } +} + +// Wraps everything except the header image. +.barWrapper { + padding-inline: 16px; +} + +// Avatar +.avatarWrapper { + margin-top: -64px; + padding-top: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + overflow: hidden; + margin-inline-start: -2px; // aligns the pfp with content below +} + +.avatar { + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--avatar-border-radius); + + .moved & { + filter: grayscale(100%); + } +} + +.displayNameWrapper { + display: flex; + align-items: start; + gap: 16px; + margin-top: 16px; + margin-bottom: 16px; + + h1 { + font-size: 17px; + line-height: 22px; + color: var(--color-text-primary); + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + :global(.emojione) { + width: 22px; + height: 22px; + } +} + +.nameWrapper { + flex-grow: 1; + min-width: 0; + overflow-wrap: break-word; +} + +.name { + > h1 { + display: inline; + font-size: 22px; + line-height: normal; + white-space: initial; + margin-inline-end: 4px; + } +} + +.badges { + margin-top: 8px; +} + +.handleHelpButton { + display: flex; + gap: 2px; + padding: 0; + margin-top: 4px; + appearance: none; + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 13px; + transition: color 0.2s ease-in-out; + word-break: break-all; + text-align: left; + + /* Allow the handle text to be selected */ + user-select: text; + + > svg { + width: 16px; + height: 16px; + } + + &:hover, + &:focus { + color: var(--color-text-brand-soft); + } +} + +.handleHelp { + padding: 16px; + background: var(--color-bg-primary); + color: var(--color-text-primary); + border-radius: 12px; + box-shadow: var(--dropdown-shadow); + max-width: 400px; + box-sizing: border-box; + + [data-color-scheme='dark'] & { + border: 1px solid var(--color-border-primary); + } + + > h3 { + font-size: 17px; + font-weight: 600; + } + + > ol { + margin: 12px 0; + } + + li { + display: flex; + gap: 8px; + align-items: start; + + &:first-child { + margin-bottom: 12px; + } + + > svg { + background: var(--color-bg-brand-softest); + width: 28px; + height: 28px; + padding: 5px; + border-radius: 9999px; + box-sizing: border-box; + } + } + + strong { + font-weight: 600; + } +} + +.handleCopy { + border: 1px solid var(--color-border-primary); + border-radius: 8px; + box-sizing: border-box; + padding: 4px 8px; + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + font-size: 13px; + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + margin-top: 12px; + + &:active, + &:focus, + &:hover { + background-color: var(--color-bg-brand-softest); + } +} + +$button-breakpoint: 420px; +$button-fallback-breakpoint: $button-breakpoint + 55px; + +.buttonsDesktop, +.buttonsMobile { + display: flex; + align-items: center; + gap: 8px; + + :global(.button) { + flex-shrink: 1; + white-space: nowrap; + min-width: 80px; + } + + :global(.icon-button) { + border: 1px solid var(--color-border-primary); + border-radius: 4px; + box-sizing: content-box; + padding: 5px; + + &:global(.copied) { + border-color: var(--color-text-success); + } + } +} + +.buttonsDesktop { + @container (width < #{$button-breakpoint}) { + display: none; + } + + @supports (not (container-type: inline-size)) { + @media (max-width: #{$button-fallback-breakpoint}) { + display: none; + } + } +} + +.buttonsMobile { + position: sticky; + bottom: var(--mobile-bottom-nav-height); + padding: 12px 16px; + margin: 0 -16px; + + @container (width >= #{$button-breakpoint}) { + display: none; + } + + @supports (not (container-type: inline-size)) { + @media (min-width: ($button-fallback-breakpoint + 1px)) { + display: none; + } + } + + // Multi-column layout + @media (width >= #{$button-breakpoint}) { + bottom: 0; + } +} + +.buttonsMobileIsStuck { + background-color: var(--color-bg-primary); + border-top: 1px solid var(--color-border-primary); +} + +.buttonMenu { + // Override the modal for mobile. + &:global(.actions-modal) { + max-height: none; + } + + li :global(.icon) { + width: 20px; + height: 20px; + } +} + +.numberFields { + @container (width >= #{$button-breakpoint}) { + --number-fields-gap: 40px; + } +} + +.familiarFollowers { + margin-top: 16px; +} + +.note { + margin-bottom: 16px; +} + +.noteContent { + white-space-collapse: preserve-breaks; +} + +.noteEditButton { + color: inherit; + + svg { + width: 20px; + height: 20px; + } +} + +.fieldList { + --cols: 4; + + position: relative; + display: grid; + grid-template-columns: repeat(var(--cols), 1fr); + gap: 4px; + margin: 16px 0; + + @container (width < 420px) { + --cols: 2; + } +} + +.fieldItem { + --col-span: 1; + + grid-column: span var(--col-span); + position: relative; + + @for $col from 2 through 4 { + &[data-cols='#{$col}'] { + --col-span: #{$col}; + } + } + + dt { + font-weight: normal; + } + + dd { + font-weight: 500; + } + + :is(dt, dd) { + text-overflow: initial; + + // Override the MiniCard link styles + a { + color: inherit; + font-weight: inherit; + + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + } + } + } + + // See: https://stackoverflow.com/questions/13226296/is-scrollwidth-property-of-a-span-not-working-on-chrome + [data-contents] { + display: inline-block; + } +} + +.fieldVerified { + background-color: var(--color-bg-success-softest); + + &.fieldItem { + border-color: var(--color-border-success-soft); + padding-right: 30px; // 8px padding + 16px for the icon + 8px gap - 2px tolerance + } +} + +.fieldVerifiedIcon { + display: block; + position: absolute; + width: 16px; + height: 16px; + top: 8px; + right: 8px; + + > svg { + width: 100%; + height: 100%; + } +} + +.fieldOverflowButton { + --default-bg-color: var(--color-bg-secondary); + --hover-bg-color: var(--color-bg-brand-softest); + + position: absolute; + right: 8px; + padding: 0 2px; + transition: background-color 0.2s ease-in-out; + border: 2px solid var(--color-bg-primary); + + > svg { + width: 16px; + height: 12px; + } +} + +.modalCloseButton { + padding: 8px; + border-radius: 50%; + border: 1px solid var(--color-border-primary); +} + +.modalTitle { + flex-grow: 1; + text-align: center; +} + +.modalFieldsList { + padding: 16px; +} + +.modalFieldItem { + &:not(:first-child) { + padding-top: 12px; + } + + &:not(:last-child)::after { + content: ''; + display: block; + border-bottom: 1px solid var(--color-border-primary); + margin-top: 12px; + } + + dt { + color: var(--color-text-secondary); + font-size: 13px; + } + + dd { + font-weight: 500; + font-size: 15px; + } + + .fieldIconVerified { + vertical-align: middle; + margin-left: 4px; + } +} + +.noTabs { + width: 100%; + border-width: 0 0 1px; + border-bottom: 1px solid var(--color-border-primary); +} + +// Banners + +.bannerWrapper, +.bannerBase { + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +.bannerWrapper { + background: var(--color-bg-tertiary); + padding: 16px; + align-items: center; +} + +.bannerBase { + border-radius: 12px; + background: var(--color-bg-secondary); + gap: 12px; + justify-content: center; + align-items: flex-start; + margin: 16px 0; + padding: 16px; +} + +.bannerBaseCentered { + min-height: 146px; + align-items: center; + + .bannerTextAndActions { + text-align: center; + } +} + +.bannerText { + color: var(--color-text-secondary); + font-size: 14px; + font-weight: 500; + text-align: center; +} + +.bannerTextAndActions { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + font-weight: 400; + color: var(--color-text-primary); + + h2 { + font-size: 17px; + font-weight: 600; + } +} + +.bannerActions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; + width: 100%; + margin-top: 16px; + + button { + width: 100%; + } +} + +.bannerDisclaimer { + a { + color: inherit; + } +} + +.bannerInputButton { + display: flex; + gap: 8px; + align-self: stretch; + align-items: flex-start; + + & > div { + flex-grow: 1; + } + + label { + font-weight: 400; + } + + :global(.button) { + margin-top: 24px; // To align with input under label + } + + input[type='email'] { + padding: 7px 8px; // To align size with button + background: var(--color-bg-primary); + } +} + +.bannerActionsDisplayName { + color: var(--color-text-secondary); + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + line-height: 22px; + overflow: hidden; + text-decoration: none; + + &:hover strong { + text-decoration: underline; + } + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + strong { + color: var(--color-text-primary); + } +} + +// Buttons + +.followButton { + flex-grow: 1; +} + +.bioButtonsWrapper { + margin-top: 16px; +} diff --git a/app/javascript/flavours/glitch/components/account_header/subscription_form.tsx b/app/javascript/flavours/glitch/components/account_header/subscription_form.tsx new file mode 100644 index 00000000000000..1f27d8a3d170b2 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/subscription_form.tsx @@ -0,0 +1,190 @@ +import { useState, useCallback, useId } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +import { AxiosError } from 'axios'; + +import { apiSubscribeByEmail } from '@/flavours/glitch/api/accounts'; +import type { + ValidationErrorResponse, + ValidationError, +} from '@/flavours/glitch/api_types/errors'; +import { useAppSelector } from '@/flavours/glitch/store'; + +import { Button } from '../button'; +import { DisplayName } from '../display_name'; +import type { FieldStatus } from '../form_fields'; +import { TextInputField } from '../form_fields/text_input_field'; + +import classes from './styles.module.scss'; + +const messages = defineMessages({ + emailInvalid: { + id: 'email_subscriptions.validation.email.invalid', + defaultMessage: 'Invalid email address', + }, + emailBlocked: { + id: 'email_subscriptions.validation.email.blocked', + defaultMessage: 'Blocked email provider', + }, + email: { + id: 'email_subscriptions.email', + defaultMessage: 'Email', + }, +}); + +const isValidationErrorResponse = ( + data: unknown, +): data is ValidationErrorResponse => + typeof data === 'object' && + data !== null && + 'error' in data && + 'details' in data; + +const fieldStatusFromErrors = ( + intl: IntlShape, + errors: ValidationError[], +): FieldStatus | undefined => { + const error = errors[0]; + + if (!error) { + return undefined; + } + + let message: string; + + switch (error.error) { + case 'ERR_BLOCKED': + message = intl.formatMessage(messages.emailBlocked); + break; + case 'ERR_INVALID': + default: + message = intl.formatMessage(messages.emailInvalid); + break; + } + + return { variant: 'error', message }; +}; + +export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({ + accountId, +}) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + const intl = useIntl(); + const accessibilityId = useId(); + + const [email, setEmail] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [errors, setErrors] = useState>({}); + + const handleChange = useCallback>( + (e) => { + setEmail(e.target.value); + setErrors({}); + }, + [], + ); + + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + + if (email.length === 0) { + return; + } + + setSubmitting(true); + + apiSubscribeByEmail(accountId, email) + .then(() => { + setSubmitting(false); + setSubmitted(true); + }) + .catch((err: unknown) => { + setSubmitting(false); + + if (err instanceof AxiosError && err.response) { + const data: unknown = err.response.data; + + if (isValidationErrorResponse(data)) { + if (data.details.email?.some((k) => k.error === 'ERR_TAKEN')) { + setSubmitted(true); + return; + } + + setErrors(data.details); + } + } + }); + }, + [accountId, email], + ); + + if (submitted) { + return ( +
+
+ + +
+
+ ); + } + + return ( +
+
+ , + }} + /> +
+ +
+ + + +
+ +
+ {str} }} + /> +
+
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/account_header/tabs.tsx b/app/javascript/flavours/glitch/components/account_header/tabs.tsx new file mode 100644 index 00000000000000..6731fea7e50ba4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_header/tabs.tsx @@ -0,0 +1,48 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import type { NavLinkProps } from 'react-router-dom'; + +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useAccountId } from '@/flavours/glitch/hooks/useAccountId'; + +import { TabLink, TabList } from '../tab_list'; + +import classes from './styles.module.scss'; + +const isActive: Required['isActive'] = (match, location) => + match?.url === location.pathname || + (!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`)); + +export const AccountTabs: FC = () => { + const accountId = useAccountId(); + const account = useAccount(accountId); + + if (!account) { + return
; + } + + const { acct, show_featured, show_media } = account; + if (!show_featured && !show_media) { + return
; + } + + return ( + + + + + {show_media && ( + + + + )} + {show_featured && ( + + + + )} + + ); +}; diff --git a/app/javascript/flavours/glitch/components/account_list_item/account_list_item.stories.tsx b/app/javascript/flavours/glitch/components/account_list_item/account_list_item.stories.tsx new file mode 100644 index 00000000000000..62875b02f67ab1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_list_item/account_list_item.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { accountFactoryState, relationshipsFactory } from '@/testing/factories'; + +import { PendingBadge } from '../badge'; + +import { AccountListItem } from './index'; + +const meta = { + title: 'Components/AccountListItem', + component: AccountListItem, + args: { + accountId: '1', + withBorder: false, + }, + parameters: { + state: { + accounts: { + '1': accountFactoryState(), + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const FollowsYou: Story = { + parameters: { + state: { + relationships: { + '1': relationshipsFactory({ + followed_by: true, + }), + }, + }, + }, +}; + +export const WithCustomStats: Story = { + args: { + stats: ['posts', 'last-active'], + }, +}; + +export const WithCustomBadge: Story = { + args: { + badge: , + }, +}; + +export const WithBorder: Story = { + args: { + withBorder: true, + }, +}; + +export const WithoutButton: Story = { + args: { + renderButton: () => null, + }, +}; diff --git a/app/javascript/flavours/glitch/components/account_list_item/index.tsx b/app/javascript/flavours/glitch/components/account_list_item/index.tsx new file mode 100644 index 00000000000000..2a357f72b267b1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_list_item/index.tsx @@ -0,0 +1,207 @@ +import type { ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { + FollowsYouBadge, + VerifiedBadge, +} from 'flavours/glitch/components/badge'; +import { useAccount } from 'flavours/glitch/hooks/useAccount'; +import { useRelationship } from 'flavours/glitch/hooks/useRelationship'; +import { domain } from 'flavours/glitch/initial_state'; +import type { Relationship } from 'flavours/glitch/models/relationship'; + +import { Avatar } from '../avatar'; +import { useAccountHandle } from '../display_name/default'; +import { DisplayNameSimple } from '../display_name/simple'; +import { EmojiHTML } from '../emoji/html'; +import { FollowButton } from '../follow_button'; +import { FormattedDateWrapper } from '../formatted_date'; +import { ListItemLink, ListItemWrapper } from '../list_item'; +import { NumberFields, NumberFieldsItem } from '../number_fields'; +import { RelativeTimestamp } from '../relative_timestamp'; +import { ShortNumber } from '../short_number'; + +import classes from './styles.module.scss'; + +export interface RenderButtonOptions { + accountId: string | undefined; + relationship: Relationship | null | undefined; +} + +type Stat = 'followers' | 'following' | 'posts' | 'joined' | 'last-active'; + +interface Props { + accountId: string | undefined; + stats?: Stat[]; + withBio?: boolean; + withBorder?: boolean; + badge?: ReactNode; + renderButton?: (options: RenderButtonOptions) => React.ReactNode; +} + +const DEFAULT_STATS: Stat[] = ['followers', 'posts', 'last-active']; + +/** + * Extended account list item with bio, verified link badge, + * and familiar follower widget. + * + * The displayed account stats can be customised using the `stats` prop, + * and button rendering can be customised via the `renderButton` prop. + */ +export const AccountListItem: React.FC = ({ + accountId, + stats = DEFAULT_STATS, + withBio = true, + withBorder = true, + badge: badgeProp, + renderButton = defaultRenderButton, +}) => { + const intl = useIntl(); + const account = useAccount(accountId); + const handle = useAccountHandle(account, domain); + const relationship = useRelationship(accountId); + + const createdThisYear = useMemo( + () => account?.created_at.includes(new Date().getFullYear().toString()), + [account?.created_at], + ); + + if (!accountId || !account) { + return null; + } + + const badge = + badgeProp ?? (relationship?.followed_by ? : null); + + const firstVerifiedField = account.fields.find((item) => !!item.verified_at); + + return ( +
+ } + sideContent={ + + {renderButton({ accountId, relationship })} + + } + > + {handle}} + > + + {badge && {badge}} + + + + + {stats.includes('followers') && ( + + } + hint={intl.formatNumber(account.followers_count)} + > + + + )} + {stats.includes('following') && ( + + } + hint={intl.formatNumber(account.following_count)} + link={`/@${account.acct}/following`} + > + + + )} + {stats.includes('posts') && ( + + } + hint={intl.formatNumber(account.statuses_count)} + > + + + )} + {stats.includes('joined') && ( + + } + hint={intl.formatDate(account.created_at)} + > + {createdThisYear ? ( + + ) : ( + + )} + + )} + {stats.includes('last-active') && ( + + } + > + {account.last_status_at ? ( + + ) : ( + '-' + )} + + )} + {firstVerifiedField && ( + + )} + + {withBio && account.note.length > 0 && ( + + )} +
+ ); +}; + +const defaultRenderButton = ({ accountId }: RenderButtonOptions) => ( + +); + +export const AccountListItemFollowButton: React.FC<{ + accountId: string | undefined; +}> = ({ accountId }) => ( + +); diff --git a/app/javascript/flavours/glitch/components/account_list_item/styles.module.scss b/app/javascript/flavours/glitch/components/account_list_item/styles.module.scss new file mode 100644 index 00000000000000..a49f47b12a4913 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account_list_item/styles.module.scss @@ -0,0 +1,55 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + + &[data-with-border='true'] { + border-bottom: 1px solid var(--color-border-primary); + } +} + +.main { + --list-item-padding: 0; +} + +.displayName { + // Spacing for badge + margin-inline-end: 6px; +} + +.badge { + // Sort out vertical alignment next to name + vertical-align: -4px; +} + +.handle { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.button { + align-self: start; +} + +.verifiedBadge { + align-self: end; +} + +.bio { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + + :any-link { + color: var(--color-text-status-links); + + &:hover { + text-decoration: none; + } + } +} diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.jsx b/app/javascript/flavours/glitch/components/admin/Dimension.jsx index de24a4bf3c017f..f0e9c12b3ac847 100644 --- a/app/javascript/flavours/glitch/components/admin/Dimension.jsx +++ b/app/javascript/flavours/glitch/components/admin/Dimension.jsx @@ -85,7 +85,7 @@ export default class Dimension extends PureComponent { return (
-

{label}

+

{label}

{content}
diff --git a/app/javascript/flavours/glitch/components/admin/ImpactReport.jsx b/app/javascript/flavours/glitch/components/admin/ImpactReport.jsx index 06d1a6a343e54b..2ca7a9560c9dd2 100644 --- a/app/javascript/flavours/glitch/components/admin/ImpactReport.jsx +++ b/app/javascript/flavours/glitch/components/admin/ImpactReport.jsx @@ -49,7 +49,7 @@ export default class ImpactReport extends PureComponent { return (
-

+ diff --git a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx index 92669fcadc9510..9ca16a0f32c557 100644 --- a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx +++ b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.jsx @@ -1,12 +1,14 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; +import { defineMessages } from 'react-intl'; import classNames from 'classnames'; import api from 'flavours/glitch/api'; +import { injectIntl } from '../intl'; + const messages = defineMessages({ legal: { id: 'report.categories.legal', defaultMessage: 'Legal' }, other: { id: 'report.categories.other', defaultMessage: 'Other' }, diff --git a/app/javascript/flavours/glitch/components/admin/Retention.jsx b/app/javascript/flavours/glitch/components/admin/Retention.jsx index e2a19d58447088..ff021d7e92f6a4 100644 --- a/app/javascript/flavours/glitch/components/admin/Retention.jsx +++ b/app/javascript/flavours/glitch/components/admin/Retention.jsx @@ -145,7 +145,7 @@ export default class Retention extends PureComponent { return (
-

{title}

+

{title}

{content}
diff --git a/app/javascript/flavours/glitch/components/admin/Trends.jsx b/app/javascript/flavours/glitch/components/admin/Trends.jsx index b64f3f90abe955..51ac2b6094a2a3 100644 --- a/app/javascript/flavours/glitch/components/admin/Trends.jsx +++ b/app/javascript/flavours/glitch/components/admin/Trends.jsx @@ -66,7 +66,7 @@ export default class Trends extends PureComponent { return (
-

+ {content}
diff --git a/app/javascript/flavours/glitch/components/alert/index.tsx b/app/javascript/flavours/glitch/components/alert/index.tsx index eb0abcb51887d7..b28d39ded5e1c9 100644 --- a/app/javascript/flavours/glitch/components/alert/index.tsx +++ b/app/javascript/flavours/glitch/components/alert/index.tsx @@ -49,7 +49,12 @@ export const Alert: React.FC<{ {hasAction && ( - )} diff --git a/app/javascript/flavours/glitch/components/alerts_controller.tsx b/app/javascript/flavours/glitch/components/alerts_controller.tsx index 150f3c028883c4..695834177a22e8 100644 --- a/app/javascript/flavours/glitch/components/alerts_controller.tsx +++ b/app/javascript/flavours/glitch/components/alerts_controller.tsx @@ -11,6 +11,7 @@ import type { } from 'flavours/glitch/models/alert'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; +import { A11yLiveRegion } from './a11y_live_region'; import { Alert } from './alert'; const formatIfNeeded = ( @@ -75,12 +76,8 @@ const TimedAlert: React.FC<{ export const AlertsController: React.FC = () => { const alerts = useAppSelector((state) => state.alerts); - if (alerts.length === 0) { - return null; - } - return ( -
+ {alerts.map((alert, idx) => ( { dismissAfter={5000 + idx * 1000} /> ))} -
+ ); }; diff --git a/app/javascript/flavours/glitch/components/alt_text_badge.tsx b/app/javascript/flavours/glitch/components/alt_text_badge.tsx deleted file mode 100644 index 9b3748b2ca63f3..00000000000000 --- a/app/javascript/flavours/glitch/components/alt_text_badge.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState, useCallback, useRef, useId } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import Overlay from 'react-overlays/Overlay'; -import type { - OffsetValue, - UsePopperOptions, -} from 'react-overlays/esm/usePopper'; - -import { useSelectableClick } from 'flavours/glitch/hooks/useSelectableClick'; - -const offset = [0, 4] as OffsetValue; -const popperConfig = { strategy: 'fixed' } as UsePopperOptions; - -export const AltTextBadge: React.FC<{ description: string }> = ({ - description, -}) => { - const accessibilityId = useId(); - const anchorRef = useRef(null); - const [open, setOpen] = useState(false); - - const handleClick = useCallback(() => { - setOpen((v) => !v); - }, [setOpen]); - - const handleClose = useCallback(() => { - setOpen(false); - }, [setOpen]); - - const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose); - - return ( - <> - - - - {({ props }) => ( -
-
-

- -

-

{description}

-
-
- )} -
- - ); -}; diff --git a/app/javascript/flavours/glitch/components/alt_text_badge/index.tsx b/app/javascript/flavours/glitch/components/alt_text_badge/index.tsx new file mode 100644 index 00000000000000..a6f55af770475b --- /dev/null +++ b/app/javascript/flavours/glitch/components/alt_text_badge/index.tsx @@ -0,0 +1,111 @@ +import { useState, useCallback, useRef, useId } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import type { + OffsetValue, + UsePopperOptions, +} from 'react-overlays/esm/usePopper'; +import Overlay from 'react-overlays/Overlay'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import { useSelectableClick } from 'flavours/glitch/hooks/useSelectableClick'; + +import { IconButton } from '../icon_button'; + +import classes from './styles.module.scss'; + +const offset = [0, 4] as OffsetValue; +const popperConfig = { strategy: 'fixed' } as UsePopperOptions; + +export const AltTextBadge: React.FC<{ + description: string; + className?: string; +}> = ({ description, className }) => { + const intl = useIntl(); + const uniqueId = useId(); + const popoverId = `${uniqueId}-popover`; + const titleId = `${uniqueId}-title`; + const buttonRef = useRef(null); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleClick = useCallback(() => { + setOpen((v) => !v); + setTimeout(() => { + popoverRef.current?.focus(); + }, 0); + }, [setOpen]); + + const handleClose = useCallback(() => { + setOpen(false); + buttonRef.current?.focus(); + }, [setOpen]); + + const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose); + + return ( + <> + + + + {({ props }) => ( +
+ +
+ )} +
+ + ); +}; diff --git a/app/javascript/flavours/glitch/components/alt_text_badge/styles.module.scss b/app/javascript/flavours/glitch/components/alt_text_badge/styles.module.scss new file mode 100644 index 00000000000000..1b7d5ec788a7c4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/alt_text_badge/styles.module.scss @@ -0,0 +1,17 @@ +.closeButton { + position: absolute; + top: 5px; + inset-inline-end: 2px; + padding: 10px; + + --default-icon-color: var(--color-text-on-media); + --default-bg-color: transparent; + --hover-icon-color: var(--color-text-on-media); + --hover-bg-color: rgb(from var(--color-text-on-media) r g b / 10%); + --focus-outline-color: var(--color-text-on-media); + + svg { + width: 20px; + height: 20px; + } +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx b/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx deleted file mode 100644 index 892d068b319249..00000000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { assetHost } from 'flavours/glitch/utils/config'; - -import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light'; - -export default class AutosuggestEmoji extends PureComponent { - - static propTypes = { - emoji: PropTypes.object.isRequired, - }; - - render () { - const { emoji } = this.props; - let url; - - if (emoji.custom) { - url = emoji.imageUrl; - } else { - const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; - - if (!mapping) { - return null; - } - - url = `${assetHost}/emoji/${mapping.filename}.svg`; - } - - return ( -
- {emoji.native - -
{emoji.colons}
-
- ); - } - -} diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx b/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx new file mode 100644 index 00000000000000..ff591fe3d9c1d5 --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx @@ -0,0 +1,20 @@ +import type { FC } from 'react'; + +import { Emoji } from './emoji'; + +interface LegacyEmoji { + id: string; + custom?: boolean; + native?: string; + imageUrl?: string; +} + +export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => { + const colons = `:${emoji.id}:`; + return ( +
+ +
{colons}
+
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.jsx b/app/javascript/flavours/glitch/components/autosuggest_input.jsx index f707a18e1d697f..4a3b7d904001a6 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_input.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_input.jsx @@ -9,8 +9,9 @@ import Overlay from 'react-overlays/Overlay'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; +import { LocalCustomEmojiProvider } from './emoji/context'; const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { let word; @@ -28,7 +29,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { return [null, null]; } - word = word.trim().toLowerCase(); + word = word.trim(); if (word.length > 0) { return [left + 1, word]; @@ -61,7 +62,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { static defaultProps = { autoFocus: true, - searchTokens: ['@', ':', '#'], + searchTokens: ['@', '@', ':', '#', '#'], }; state = { @@ -159,8 +160,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 }); } } @@ -219,15 +220,17 @@ export default class AutosuggestInput extends ImmutablePureComponent { spellCheck={spellCheck} /> - - {({ props }) => ( -
-
- {suggestions.map(this.renderSuggestion)} + + + {({ props }) => ( +
+
+ {suggestions.map(this.renderSuggestion)} +
-
- )} - + )} + +
); } diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx index de5accc4b287df..fb99f62a73d260 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx @@ -10,8 +10,9 @@ import Textarea from 'react-textarea-autosize'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; +import { LocalCustomEmojiProvider } from './emoji/context'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; @@ -25,11 +26,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { + if (!word || word.trim().length < 3 || ['@', '@', ':', '#', '#'].indexOf(word[0]) === -1) { return [null, null]; } - word = word.trim().toLowerCase(); + word = word.trim(); if (word.length > 0) { return [left + 1, word]; @@ -50,6 +51,7 @@ const AutosuggestTextarea = forwardRef(({ onKeyUp, onKeyDown, onPaste, + onDrop, onFocus, autoFocus = true, lang, @@ -150,12 +152,15 @@ const AutosuggestTextarea = forwardRef(({ }, [suggestions, onSuggestionSelected, textareaRef]); const handlePaste = useCallback((e) => { - if (e.clipboardData && e.clipboardData.files.length === 1) { - onPaste(e.clipboardData.files); - e.preventDefault(); - } + onPaste(e); }, [onPaste]); + const handleDrop = useCallback((e) => { + if (onDrop) { + onDrop(e); + } + }, [onDrop]); + // Show the suggestions again whenever they change and the textarea is focused useEffect(() => { if (suggestions.size > 0 && textareaRef.current === document.activeElement) { @@ -207,21 +212,24 @@ const AutosuggestTextarea = forwardRef(({ onFocus={handleFocus} onBlur={handleBlur} onPaste={handlePaste} + onDrop={handleDrop} dir='auto' aria-autocomplete='list' aria-label={placeholder} lang={lang} /> - - {({ props }) => ( -
-
- {suggestions.map(renderSuggestion)} + + + {({ props }) => ( +
+
+ {suggestions.map(renderSuggestion)} +
-
- )} - + )} + +
); }); @@ -238,6 +246,7 @@ AutosuggestTextarea.propTypes = { onKeyUp: PropTypes.func, onKeyDown: PropTypes.func, onPaste: PropTypes.func.isRequired, + onDrop: PropTypes.func, onFocus:PropTypes.func, autoFocus: PropTypes.bool, lang: PropTypes.string, diff --git a/app/javascript/flavours/glitch/components/avatar.tsx b/app/javascript/flavours/glitch/components/avatar.tsx index 46fe2e30b689ff..030c577d497766 100644 --- a/app/javascript/flavours/glitch/components/avatar.tsx +++ b/app/javascript/flavours/glitch/components/avatar.tsx @@ -7,10 +7,13 @@ import { useHovering } from 'flavours/glitch/hooks/useHovering'; import { autoPlayGif } from 'flavours/glitch/initial_state'; import type { Account } from 'flavours/glitch/models/account'; +import { useAccount } from '../hooks/useAccount'; + interface Props { account: | Pick | undefined; // FIXME: remove `undefined` once we know for sure its always there + alt?: string; size?: number; style?: React.CSSProperties; inline?: boolean; @@ -23,6 +26,7 @@ interface Props { export const Avatar: React.FC = ({ account, + alt = '', animate = autoPlayGif, size = 20, inline = false, @@ -53,7 +57,7 @@ export const Avatar: React.FC = ({ }, [setError]); const avatar = ( -
= ({ data-avatar-of={account && `@${account.acct}`} > {src && !error && ( - + {alt} )} {counter && ( -
{counter} -
+ )} -
+ ); if (withLink) { @@ -92,3 +96,10 @@ export const Avatar: React.FC = ({ return avatar; }; + +export const AvatarById: React.FC< + { accountId: string | undefined } & Omit +> = ({ accountId, ...otherProps }) => { + const account = useAccount(accountId); + return ; +}; diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.tsx b/app/javascript/flavours/glitch/components/avatar_overlay.tsx index 66d85325ed7b19..478facfa6181f3 100644 --- a/app/javascript/flavours/glitch/components/avatar_overlay.tsx +++ b/app/javascript/flavours/glitch/components/avatar_overlay.tsx @@ -10,6 +10,14 @@ interface Props { overlaySize?: number; } +const handleImgLoadError = (error: { currentTarget: HTMLElement }) => { + // + // When the img tag fails to load the image, set the img tag to display: none. This prevents the + // alt-text from overrunning the containing div. + // + error.currentTarget.style.display = 'none'; +}; + export const AvatarOverlay: React.FC = ({ account, friend, @@ -39,7 +47,13 @@ export const AvatarOverlay: React.FC = ({ style={{ width: `${baseSize}px`, height: `${baseSize}px` }} data-avatar-of={`@${account?.get('acct')}`} > - {accountSrc && {account?.get('acct')}} + {accountSrc && ( + {account?.get('acct')} + )}
@@ -48,7 +62,13 @@ export const AvatarOverlay: React.FC = ({ style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }} data-avatar-of={`@${friend?.get('acct')}`} > - {friendSrc && {friend?.get('acct')}} + {friendSrc && ( + {friend?.get('acct')} + )}
diff --git a/app/javascript/flavours/glitch/components/badge.jsx b/app/javascript/flavours/glitch/components/badge.jsx deleted file mode 100644 index 2a335d7f5062fb..00000000000000 --- a/app/javascript/flavours/glitch/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/flavours/glitch/components/badge/badge.stories.tsx b/app/javascript/flavours/glitch/components/badge/badge.stories.tsx new file mode 100644 index 00000000000000..3730cb18146033 --- /dev/null +++ b/app/javascript/flavours/glitch/components/badge/badge.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import CelebrationIcon from '@/material-icons/400-24px/celebration-fill.svg?react'; + +import * as badges from '.'; + +const meta = { + component: badges.Badge, + title: 'Components/Badge', + args: { + domain: '', + label: undefined, + }, + argTypes: { + domain: { + control: 'text', + }, + }, +} 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 Verified: Story = { + render() { + return ; + }, +}; + +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/flavours/glitch/components/badge/index.tsx b/app/javascript/flavours/glitch/components/badge/index.tsx new file mode 100644 index 00000000000000..591abbc75d63e3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/badge/index.tsx @@ -0,0 +1,203 @@ +import type { FC, ReactNode } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import type { OnAttributeHandler } from '@/flavours/glitch/utils/html'; +import AdminIcon from '@/images/icons/icon_admin.svg?react'; +import ClockIcon from '@/images/icons/icon_clock.svg?react'; +import FollowerIcon from '@/images/icons/icon_follower.svg?react'; +import IconVerified from '@/images/icons/icon_verified.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'; + +import { EmojiHTML } from '../emoji/html'; +import { Icon } from '../icon'; + +import classes from './styles.module.scss'; + +interface BadgeProps extends React.ComponentPropsWithoutRef<'div'> { + label: ReactNode; + icon?: ReactNode; + domain?: ReactNode; + roleId?: string; + variant?: + | 'default' + | 'subtle' + | 'inverted' + | 'success' + | 'warning' + | 'danger'; +} + +type PresetBadgeProps = Omit< + BadgeProps, + 'label' | 'icon' | 'domain' | 'roleId' +>; + +export const Badge: FC = ({ + icon = , + variant = 'default', + label, + className, + domain, + roleId, + ...otherProps +}) => ( +
+ {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 = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const FollowsYouBadge: FC = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const PendingBadge: FC = (props) => ( + } + label={} + {...props} + /> +); + +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 ( + } + variant='inverted' + label={ + label ?? + (formattedDate ? ( + + ) : ( + + )) + } + {...props} + /> + ); +}; + +export const BlockedBadge: FC> = ({ label, ...props }) => ( + } + variant='danger' + label={ + label ?? ( + + ) + } + {...props} + /> +); + +const onAttribute: OnAttributeHandler = (name, value, tagName) => { + if (name === 'rel' && tagName === 'a') { + if (value === 'me') { + return null; + } + return [ + name, + value + .split(' ') + .filter((x) => x !== 'me') + .join(' '), + ]; + } + return undefined; +}; + +export const VerifiedBadge: React.FC<{ link: string; className?: string }> = ({ + link, + className, +}) => ( + } + label={} + className={className} + /> +); diff --git a/app/javascript/flavours/glitch/components/badge/styles.module.scss b/app/javascript/flavours/glitch/components/badge/styles.module.scss new file mode 100644 index 00000000000000..b216e3eb206ab6 --- /dev/null +++ b/app/javascript/flavours/glitch/components/badge/styles.module.scss @@ -0,0 +1,70 @@ +.badge { + color: var(--color-text-primary); + font-size: 13px; + font-weight: 400; + display: inline-flex; + max-width: 100%; + padding: 4px; + gap: 4px; + border-radius: 8px; + align-items: center; + + > svg { + flex-shrink: 0; + width: auto; + height: 17px; + fill: currentColor; + opacity: 0.85; + } + + a { + color: inherit; + text-decoration: none; + } + + &:not(.badgeWithoutIcon) { + padding-inline-end: 8px; + } +} + +.content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.domain { + opacity: 0.75; + letter-spacing: 0; +} + +.default { + background-color: var(--color-bg-secondary); +} + +.subtle { + background-color: var(--color-bg-brand-softest); +} + +.feature { + background-color: var(--color-bg-brand-base); + color: var(--color-text-on-brand-base); +} + +.inverted { + background-color: var(--color-bg-inverted); + color: var(--color-text-inverted); +} + +.success { + background-color: var(--color-bg-success-softest); +} + +.warning { + background-color: var(--color-bg-warning-softest); +} + +.danger { + background-color: var(--color-bg-error-base); + color: var(--color-text-on-error-base); +} diff --git a/app/javascript/flavours/glitch/components/blurhash.tsx b/app/javascript/flavours/glitch/components/blurhash.tsx index 8e2a8af23e5937..b7331755b7f0dd 100644 --- a/app/javascript/flavours/glitch/components/blurhash.tsx +++ b/app/javascript/flavours/glitch/components/blurhash.tsx @@ -30,9 +30,12 @@ const Blurhash: React.FC = ({ try { const pixels = decode(hash, width, height); const ctx = canvas.getContext('2d'); - const imageData = new ImageData(pixels, width, height); + const imageData = ctx?.createImageData(width, height); + imageData?.data.set(pixels); - ctx?.putImageData(imageData, 0, 0); + if (imageData) { + ctx?.putImageData(imageData, 0, 0); + } } catch (err) { console.error('Blurhash decoding failure', { err, hash }); } diff --git a/app/javascript/flavours/glitch/components/button/index.tsx b/app/javascript/flavours/glitch/components/button/index.tsx index 4ef61e1e14b1fa..fb107c78e0047d 100644 --- a/app/javascript/flavours/glitch/components/button/index.tsx +++ b/app/javascript/flavours/glitch/components/button/index.tsx @@ -5,8 +5,10 @@ import classNames from 'classnames'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; -interface BaseProps - extends Omit, 'children'> { +interface BaseProps extends Omit< + React.ButtonHTMLAttributes, + 'children' +> { block?: boolean; secondary?: boolean; plain?: boolean; @@ -78,6 +80,7 @@ export const Button: React.FC = ({ aria-live={loading !== undefined ? 'polite' : undefined} onClick={handleClick} title={title} + // eslint-disable-next-line react/button-has-type -- set correctly via TS type={type} {...props} > diff --git a/app/javascript/flavours/glitch/components/callout/callout.stories.tsx b/app/javascript/flavours/glitch/components/callout/callout.stories.tsx new file mode 100644 index 00000000000000..bb973ab71f154c --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout/callout.stories.tsx @@ -0,0 +1,102 @@ +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 NoTitle: Story = { + args: { + title: '', + primaryLabel: '', + secondaryLabel: '', + onClose: undefined, + }, +}; + +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/flavours/glitch/components/callout/dismissible.tsx b/app/javascript/flavours/glitch/components/callout/dismissible.tsx new file mode 100644 index 00000000000000..f447c1eae98fa3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout/dismissible.tsx @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { useDismissible } from '@/flavours/glitch/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/flavours/glitch/components/callout/index.tsx b/app/javascript/flavours/glitch/components/callout/index.tsx new file mode 100644 index 00000000000000..fe088e2a830f57 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout/index.tsx @@ -0,0 +1,154 @@ +import type { FC, ReactElement, 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 | ReactElement; + onSecondary?: () => void; + secondaryLabel?: string | ReactElement; + 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/flavours/glitch/components/callout/styles.module.css b/app/javascript/flavours/glitch/components/callout/styles.module.css new file mode 100644 index 00000000000000..f049f25e983529 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout/styles.module.css @@ -0,0 +1,143 @@ +.wrapper { + display: flex; + align-items: start; + padding: 12px; + gap: 8px; + background-color: var(--color-bg-brand-softest); + color: var(--color-text-primary); + border-radius: 12px; + font-size: 15px; + line-height: 1.3333; +} + +.icon { + padding: 4px; + border-radius: 9999px; + width: 1rem; + height: 1rem; + margin-block: -2px; +} + +.content, +.body { + min-width: 0; +} + +.content { + display: flex; + gap: 8px; + flex-direction: column; + flex-grow: 1; +} + +@media screen and (width >= 630px) { + .content { + flex-direction: row; + } +} + +.body { + flex-grow: 1; + overflow-wrap: break-word; + hyphens: auto; + + a { + color: inherit; + } + + h3 { + font-weight: 500; + margin-bottom: 3px; + } +} + +.actionWrapper { + display: flex; + gap: 8px; + align-items: start; +} + +.action { + appearance: none; + background: none; + border: none; + color: inherit; + font: inherit; + font-weight: 500; + padding: 0; + text-wrap: nowrap; + 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-border-brand-soft); + background-color: var(--color-bg-primary); + + .icon { + background-color: var(--color-bg-brand-softest); + } +} + +.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-inverted); +} + +.variantSuccess { + background-color: var(--color-bg-success-softest); + + .icon { + background-color: var(--color-bg-success-soft); + } +} + +.variantWarning { + background-color: var(--color-bg-warning-softest); + + .icon { + background-color: var(--color-bg-warning-soft); + } +} + +.variantError { + background-color: var(--color-bg-error-softest); + + .icon { + background-color: var(--color-bg-error-soft); + } +} diff --git a/app/javascript/flavours/glitch/components/callout_inline/callout_inline.stories.tsx b/app/javascript/flavours/glitch/components/callout_inline/callout_inline.stories.tsx new file mode 100644 index 00000000000000..f18af41dc0d1e5 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout_inline/callout_inline.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CalloutInline } from '.'; + +const meta = { + title: 'Components/CalloutInline', + args: { + children: 'Contents here', + }, + component: CalloutInline, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Error: Story = { + args: { + variant: 'error', + }, +}; + +export const Warning: Story = { + args: { + variant: 'warning', + }, +}; + +export const Success: Story = { + args: { + variant: 'success', + }, +}; + +export const Info: Story = { + args: { + variant: 'info', + }, +}; diff --git a/app/javascript/flavours/glitch/components/callout_inline/index.tsx b/app/javascript/flavours/glitch/components/callout_inline/index.tsx new file mode 100644 index 00000000000000..b8c571e4ea324e --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout_inline/index.tsx @@ -0,0 +1,35 @@ +import type { FC } from 'react'; + +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.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 { FieldStatus } from '../form_fields/form_field_wrapper'; +import { Icon } from '../icon'; + +import classes from './styles.module.css'; + +const iconMap: Record = { + error: ErrorIcon, + warning: WarningIcon, + info: InfoIcon, + success: CheckIcon, +}; + +export const CalloutInline: FC< + Partial & React.ComponentPropsWithoutRef<'div'> +> = ({ variant = 'error', message, className, children, ...props }) => { + return ( +
+ + {message ?? children} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/callout_inline/styles.module.css b/app/javascript/flavours/glitch/components/callout_inline/styles.module.css new file mode 100644 index 00000000000000..8d32f7df9b4742 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout_inline/styles.module.css @@ -0,0 +1,29 @@ +.wrapper { + display: flex; + align-items: start; + gap: 4px; + font-size: 13px; + font-weight: 500; + + &[data-variant='success'] { + color: var(--color-text-success); + } + + &[data-variant='warning'] { + color: var(--color-text-warning); + } + + &[data-variant='error'] { + color: var(--color-text-error); + } + + &[data-variant='info'] { + color: var(--color-text-primary); + } +} + +.icon { + width: 16px; + height: 16px; + margin-top: 1px; +} diff --git a/app/javascript/flavours/glitch/components/carousel/carousel.stories.tsx b/app/javascript/flavours/glitch/components/carousel/carousel.stories.tsx new file mode 100644 index 00000000000000..5117bc08e3530d --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/carousel.stories.tsx @@ -0,0 +1,126 @@ +import type { FC } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn, userEvent, expect } from 'storybook/test'; + +import type { CarouselProps } from './index'; +import { Carousel } from './index'; + +interface TestSlideProps { + id: number; + text: string; + color: string; +} + +const TestSlide: FC = ({ + active, + text, + color, +}) => ( +
+ {text} +
+); + +const slides: TestSlideProps[] = [ + { + id: 1, + text: 'first', + color: 'red', + }, + { + id: 2, + text: 'second', + color: 'pink', + }, + { + id: 3, + text: 'third', + color: 'orange', + }, +]; + +type StoryProps = Pick< + CarouselProps, + 'items' | 'renderItem' | 'emptyFallback' | 'onChangeSlide' +>; + +const meta = { + title: 'Components/Carousel', + args: { + items: slides, + renderItem(item, active) { + return ; + }, + onChangeSlide: fn(), + emptyFallback: 'No slides available', + }, + render(args) { + return ( + <> + + + + ); + }, + argTypes: { + emptyFallback: { + type: 'string', + }, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + async play({ args, canvas }) { + const nextButton = await canvas.findByRole('button', { name: /next/i }); + const slides = await canvas.findAllByRole('group'); + await expect(slides).toHaveLength(slides.length); + + await userEvent.click(nextButton); + await expect(args.onChangeSlide).toHaveBeenCalledWith(1, slides[1]); + + await userEvent.click(nextButton); + await expect(args.onChangeSlide).toHaveBeenCalledWith(2, slides[2]); + + // Wrap around + await userEvent.click(nextButton); + await expect(args.onChangeSlide).toHaveBeenCalledWith(0, slides[0]); + }, +}; + +export const DifferentHeights: Story = { + args: { + items: slides.map((props, index) => ({ + ...props, + styles: { height: 100 + index * 100 }, + })), + }, +}; + +export const NoSlides: Story = { + args: { + items: [], + }, +}; diff --git a/app/javascript/flavours/glitch/components/carousel/index.tsx b/app/javascript/flavours/glitch/components/carousel/index.tsx new file mode 100644 index 00000000000000..bc287aa969132c --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/index.tsx @@ -0,0 +1,244 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { + ComponentPropsWithoutRef, + ComponentType, + ReactElement, + ReactNode, +} from 'react'; + +import type { MessageDescriptor } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { usePrevious } from '@dnd-kit/utilities'; +import { animated, useSpring } from '@react-spring/web'; +import { useDrag } from '@use-gesture/react'; + +import type { CarouselPaginationProps } from './pagination'; +import { CarouselPagination } from './pagination'; + +import './styles.scss'; + +const defaultMessages = defineMessages({ + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, + current: { + id: 'carousel.current', + defaultMessage: 'Slide {current, number} / {max, number}', + }, + slide: { + id: 'carousel.slide', + defaultMessage: 'Slide {current, number} of {max, number}', + }, +}); + +export type MessageKeys = keyof typeof defaultMessages; + +export interface CarouselSlideProps { + id: string | number; +} + +export type RenderSlideFn< + SlideProps extends CarouselSlideProps = CarouselSlideProps, +> = (item: SlideProps, active: boolean, index: number) => ReactElement; + +export interface CarouselProps< + SlideProps extends CarouselSlideProps = CarouselSlideProps, +> { + items: SlideProps[]; + renderItem: RenderSlideFn; + onChangeSlide?: (index: number, ref: Element) => void; + paginationComponent?: ComponentType | null; + paginationProps?: Partial; + messages?: Record; + emptyFallback?: ReactNode; + classNamePrefix?: string; + slideClassName?: string; +} + +export const Carousel = < + SlideProps extends CarouselSlideProps = CarouselSlideProps, +>({ + items, + renderItem, + onChangeSlide, + paginationComponent: Pagination = CarouselPagination, + paginationProps = {}, + messages = defaultMessages, + children, + emptyFallback = null, + className, + classNamePrefix = 'carousel', + slideClassName, + ...wrapperProps +}: CarouselProps & ComponentPropsWithoutRef<'div'>) => { + // Handle slide change + const [slideIndex, setSlideIndex] = useState(0); + const wrapperRef = useRef(null); + // Handle slide heights + const [currentSlideHeight, setCurrentSlideHeight] = useState( + () => wrapperRef.current?.scrollHeight ?? 0, + ); + const previousSlideHeight = usePrevious(currentSlideHeight); + const handleSlideChange = useCallback( + (direction: number) => { + setSlideIndex((prev) => { + const max = items.length - 1; + let newIndex = prev + direction; + if (newIndex < 0) { + newIndex = max; + } else if (newIndex > max) { + newIndex = 0; + } + + const slide = wrapperRef.current?.children[newIndex]; + if (slide) { + setCurrentSlideHeight(slide.scrollHeight); + if (slide instanceof HTMLElement) { + onChangeSlide?.(newIndex, slide); + } + } + + return newIndex; + }); + }, + [items.length, onChangeSlide], + ); + + const observerRef = useRef(null); + observerRef.current ??= new ResizeObserver(() => { + handleSlideChange(0); + }); + + const wrapperStyles = useSpring({ + x: `-${slideIndex * 100}%`, + height: currentSlideHeight, + // Don't animate from zero to the height of the initial slide + immediate: !previousSlideHeight, + }); + useLayoutEffect(() => { + // Update slide height when the component mounts + if (currentSlideHeight === 0) { + handleSlideChange(0); + } + }, [currentSlideHeight, handleSlideChange]); + + // Handle swiping animations + const bind = useDrag( + ({ swipe: [swipeX] }) => { + handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide. + }, + { pointer: { capture: false } }, + ); + const handlePrev = useCallback(() => { + handleSlideChange(-1); + // We're focusing on the wrapper as the child slides can potentially be inert. + // Because of that, only the active slide can be focused anyway. + wrapperRef.current?.focus(); + }, [handleSlideChange]); + const handleNext = useCallback(() => { + handleSlideChange(1); + wrapperRef.current?.focus(); + }, [handleSlideChange]); + + const intl = useIntl(); + + if (items.length === 0) { + return emptyFallback; + } + + return ( +
+
+ {children} + {Pagination && items.length > 1 && ( + + )} +
+ + + {items.map((itemsProps, index) => ( + + item={itemsProps} + renderItem={renderItem} + observer={observerRef.current} + index={index} + key={`slide-${itemsProps.id}`} + className={classNames(`${classNamePrefix}__slide`, slideClassName, { + active: index === slideIndex, + })} + active={index === slideIndex} + /> + ))} + +
+ ); +}; + +type CarouselSlideWrapperProps = { + observer: ResizeObserver | null; + className: string; + active: boolean; + item: SlideProps; + index: number; +} & Pick, 'renderItem'>; + +const CarouselSlideWrapper = ({ + observer, + className, + active, + renderItem, + item, + index, +}: CarouselSlideWrapperProps) => { + const handleRef = useCallback( + (instance: HTMLDivElement | null) => { + if (observer && instance) { + observer.observe(instance); + } + }, + [observer], + ); + + const children = useMemo( + () => renderItem(item, active, index), + [renderItem, item, active, index], + ); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/carousel/pagination.tsx b/app/javascript/flavours/glitch/components/carousel/pagination.tsx new file mode 100644 index 00000000000000..a2666f486fe2ab --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/pagination.tsx @@ -0,0 +1,54 @@ +import type { FC, MouseEventHandler } from 'react'; + +import type { MessageDescriptor } from 'react-intl'; +import { useIntl } from 'react-intl'; + +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; + +import { IconButton } from '../icon_button'; + +import type { MessageKeys } from './index'; + +export interface CarouselPaginationProps { + onNext: MouseEventHandler; + onPrev: MouseEventHandler; + current: number; + max: number; + className?: string; + messages: Record; +} + +export const CarouselPagination: FC = ({ + onNext, + onPrev, + current, + max, + className = '', + messages, +}) => { + const intl = useIntl(); + return ( +
+ + + {intl.formatMessage(messages.current, { + current: current + 1, + max, + sr: (chunk) => {chunk}, + })} + + +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/carousel/styles.scss b/app/javascript/flavours/glitch/components/carousel/styles.scss new file mode 100644 index 00000000000000..bcd0bc7d3af76b --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/styles.scss @@ -0,0 +1,28 @@ +.carousel { + gap: 16px; + overflow: hidden; + touch-action: pan-y; + + &__header { + padding: 8px 16px; + } + + &__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + } + + &__slides { + display: flex; + flex-wrap: nowrap; + align-items: start; + } + + &__slide { + flex: 0 0 100%; + width: 100%; + overflow: hidden; + } +} diff --git a/app/javascript/flavours/glitch/components/character_counter/character_counter.stories.tsx b/app/javascript/flavours/glitch/components/character_counter/character_counter.stories.tsx new file mode 100644 index 00000000000000..a37a74af45e93a --- /dev/null +++ b/app/javascript/flavours/glitch/components/character_counter/character_counter.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CharacterCounter } from './index'; + +const meta = { + component: CharacterCounter, + title: 'Components/CharacterCounter', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Required: Story = { + args: { + currentString: 'Hello, world!', + maxLength: 100, + }, +}; + +export const ExceedingLimit: Story = { + args: { + currentString: 'Hello, world!', + maxLength: 10, + }, +}; + +export const Recommended: Story = { + args: { + currentString: 'Hello, world!', + maxLength: 10, + recommended: true, + }, +}; diff --git a/app/javascript/flavours/glitch/components/character_counter/index.tsx b/app/javascript/flavours/glitch/components/character_counter/index.tsx new file mode 100644 index 00000000000000..6bc88c23ac520e --- /dev/null +++ b/app/javascript/flavours/glitch/components/character_counter/index.tsx @@ -0,0 +1,60 @@ +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { length } from 'stringz'; + +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import classes from './styles.module.scss'; + +interface CharacterCounterProps { + currentString: string; + maxLength: number; + recommended?: boolean; +} + +export const CharacterCounter = polymorphicForwardRef< + 'span', + CharacterCounterProps +>( + ( + { + currentString, + maxLength, + as: Component = 'span', + recommended = false, + className, + ...props + }, + ref, + ) => { + const currentLength = length(currentString); + return ( + maxLength && !recommended && classes.counterError, + )} + > + {recommended ? ( + + ) : ( + + )} + + ); + }, +); +CharacterCounter.displayName = 'CharCounter'; diff --git a/app/javascript/flavours/glitch/components/character_counter/styles.module.scss b/app/javascript/flavours/glitch/components/character_counter/styles.module.scss new file mode 100644 index 00000000000000..ae77be1554e03c --- /dev/null +++ b/app/javascript/flavours/glitch/components/character_counter/styles.module.scss @@ -0,0 +1,9 @@ +.counter { + display: block; + margin-top: 4px; + font-size: 13px; +} + +.counterError { + color: var(--color-text-error); +} diff --git a/app/javascript/flavours/glitch/components/column.tsx b/app/javascript/flavours/glitch/components/column.tsx index 0830b89f8e4cfa..804d83ede020bb 100644 --- a/app/javascript/flavours/glitch/components/column.tsx +++ b/app/javascript/flavours/glitch/components/column.tsx @@ -1,6 +1,8 @@ import { forwardRef, useRef, useImperativeHandle } from 'react'; import type { Ref } from 'react'; +import classNames from 'classnames'; + import { scrollTop } from 'flavours/glitch/scroll'; export interface ColumnRef { @@ -12,10 +14,11 @@ interface ColumnProps { children?: React.ReactNode; label?: string; bindToDocument?: boolean; + className?: string; } export const Column = forwardRef( - ({ children, label, bindToDocument }, ref: Ref) => { + ({ children, label, bindToDocument, className }, ref: Ref) => { const nodeRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -39,7 +42,12 @@ export const Column = forwardRef( })); return ( -
+
{children}
); diff --git a/app/javascript/flavours/glitch/components/column_back_button.tsx b/app/javascript/flavours/glitch/components/column_back_button.tsx index c2afa788cb5d5b..e365db27cb8127 100644 --- a/app/javascript/flavours/glitch/components/column_back_button.tsx +++ b/app/javascript/flavours/glitch/components/column_back_button.tsx @@ -4,8 +4,11 @@ import { FormattedMessage } from 'react-intl'; import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; +import { getColumnSkipLinkId } from 'flavours/glitch/features/ui/components/skip_links'; import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context'; +import { useColumnIndexContext } from '../features/ui/components/columns_area'; + import { useAppHistory } from './router'; type OnClickCallback = () => void; @@ -28,9 +31,15 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({ onClick, }) => { const handleClick = useHandleClick(onClick); + const columnIndex = useColumnIndexContext(); const component = ( - @@ -67,11 +74,12 @@ const BackButton: React.FC<{ }; export interface Props { - title?: string; + title?: React.ReactNode; icon?: string; iconComponent?: IconProp; active?: boolean; children?: React.ReactNode; + className?: string; pinned?: boolean; multiColumn?: boolean; extraButton?: React.ReactNode; @@ -90,6 +98,7 @@ export const ColumnHeader: React.FC = ({ iconComponent, active, children, + className, pinned, multiColumn, extraButton, @@ -140,11 +149,11 @@ export const ColumnHeader: React.FC = ({ onPin?.(); }, [history, pinned, onPin]); - const wrapperClassName = classNames('column-header__wrapper', { + const wrapperClassName = classNames('column-header__wrapper', className, { active, }); - const buttonClassName = classNames('column-header', { + const headingClassName = classNames('column-header', { active, }); @@ -172,6 +181,7 @@ export const ColumnHeader: React.FC = ({ @@ -193,6 +204,7 @@ export const ColumnHeader: React.FC = ({ aria-label={intl.formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={handleMoveRight} + type='button' > @@ -203,6 +215,7 @@ export const ColumnHeader: React.FC = ({ - + + {onClick ? ( + + ) : ( + + {titleContents} + + )} + )} - {!hasTitle && backButton} -
{extraButton} {collapseButton}
- +
(null); const [value, setValue] = useState(''); - useEffect(() => { + // Reset the component when it turns from active to inactive. + // [More on this pattern](https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes) + const [previousActive, setPreviousActive] = useState(active); + if (active !== previousActive) { + setPreviousActive(active); if (!active) { setValue(''); } - }, [active]); + } const handleChange = useCallback( ({ target: { value } }: React.ChangeEvent) => { diff --git a/app/javascript/flavours/glitch/components/content_warning.tsx b/app/javascript/flavours/glitch/components/content_warning.tsx index 2312d69c278dcf..a2a27849b16929 100644 --- a/app/javascript/flavours/glitch/components/content_warning.tsx +++ b/app/javascript/flavours/glitch/components/content_warning.tsx @@ -1,25 +1,48 @@ +import type { List } from 'immutable'; + +import type { CustomEmoji } from '../models/custom_emoji'; +import type { Status } from '../models/status'; + +import { EmojiHTML } from './emoji/html'; import type { IconName } from './media_icon'; import { MediaIcon } from './media_icon'; import { StatusBanner, BannerVariant } from './status_banner'; export const ContentWarning: React.FC<{ - text: string; + status: Status; expanded?: boolean; onClick?: () => void; icons?: IconName[]; -}> = ({ text, expanded, onClick, icons }) => ( - - {icons?.map((icon) => ( - = ({ status, expanded, onClick, icons }) => { + const hasSpoiler = !!status.get('spoiler_text'); + if (!hasSpoiler) { + return null; + } + + const text = + status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml'); + if (typeof text !== 'string' || text.length === 0) { + return null; + } + + return ( + + {icons?.map((icon) => ( + + ))} + } /> - ))} - - -); + + ); +}; diff --git a/app/javascript/flavours/glitch/components/copy_button.tsx b/app/javascript/flavours/glitch/components/copy_button.tsx new file mode 100644 index 00000000000000..cb1ad29b80fdaf --- /dev/null +++ b/app/javascript/flavours/glitch/components/copy_button.tsx @@ -0,0 +1,75 @@ +import { useState, useCallback } from 'react'; + +import { defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; +import { showAlert } from 'flavours/glitch/actions/alerts'; +import { IconButton } from 'flavours/glitch/components/icon_button'; +import { useAppDispatch } from 'flavours/glitch/store'; + +import { Button } from './button'; + +const messages = defineMessages({ + copied: { + id: 'copy_icon_button.copied', + defaultMessage: 'Copied to clipboard', + }, +}); + +export function useCopyToClipboard({ text }: { text: string }) { + const [wasCopied, setWasCopied] = useState(false); + const dispatch = useAppDispatch(); + + const copyText = useCallback(() => { + void navigator.clipboard.writeText(text); + setWasCopied(true); + dispatch(showAlert({ message: messages.copied })); + setTimeout(() => { + setWasCopied(false); + }, 700); + }, [setWasCopied, text, dispatch]); + + return { copyText, wasCopied }; +} + +export const CopyButton: React.FC< + Omit< + React.ComponentPropsWithoutRef, + 'onClick' | 'text' | 'children' + > & { + value: string; + children: React.ReactNode | ((wasCopied: boolean) => React.ReactNode); + } +> = ({ value, children, ...otherProps }) => { + const { copyText, wasCopied } = useCopyToClipboard({ text: value }); + + const label = typeof children === 'function' ? children(wasCopied) : children; + + return ( + + ); +}; + +export const CopyIconButton: React.FC<{ + title: string; + value: string; + className?: string; + 'aria-describedby'?: string; +}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => { + const { copyText, wasCopied } = useCopyToClipboard({ text: value }); + + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/components/copy_icon_button.tsx b/app/javascript/flavours/glitch/components/copy_icon_button.tsx deleted file mode 100644 index b890508ab27ffd..00000000000000 --- a/app/javascript/flavours/glitch/components/copy_icon_button.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useState, useCallback } from 'react'; - -import { defineMessages } from 'react-intl'; - -import classNames from 'classnames'; - -import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; -import { showAlert } from 'flavours/glitch/actions/alerts'; -import { IconButton } from 'flavours/glitch/components/icon_button'; -import { useAppDispatch } from 'flavours/glitch/store'; - -const messages = defineMessages({ - copied: { - id: 'copy_icon_button.copied', - defaultMessage: 'Copied to clipboard', - }, -}); - -export const CopyIconButton: React.FC<{ - title: string; - value: string; - className: string; -}> = ({ title, value, className }) => { - const [copied, setCopied] = useState(false); - const dispatch = useAppDispatch(); - - const handleClick = useCallback(() => { - void navigator.clipboard.writeText(value); - setCopied(true); - dispatch(showAlert({ message: messages.copied })); - setTimeout(() => { - setCopied(false); - }, 700); - }, [setCopied, value, dispatch]); - - return ( - - ); -}; diff --git a/app/javascript/flavours/glitch/components/copy_paste_text.tsx b/app/javascript/flavours/glitch/components/copy_paste_text.tsx index 053654b6e0f561..abe63cef63da64 100644 --- a/app/javascript/flavours/glitch/components/copy_paste_text.tsx +++ b/app/javascript/flavours/glitch/components/copy_paste_text.tsx @@ -74,7 +74,7 @@ export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => { onBlur={handleBlur} /> - @@ -248,9 +233,7 @@ export const DropdownMenu = ({ target={option.target ?? '_target'} data-method={option.method} rel='noopener' - ref={i === 0 ? handleFocusedItemRef : undefined} onClick={handleItemClick} - onKeyUp={handleItemKeyUp} data-index={i} > @@ -258,13 +241,7 @@ export const DropdownMenu = ({ ); } else { element = ( - + ); @@ -307,15 +284,7 @@ export const DropdownMenu = ({ })} > {items.map((option, i) => - renderItemMethod( - option, - i, - { - onClick: handleItemClick, - onKeyUp: handleItemKeyUp, - }, - i === 0 ? handleFocusedItemRef : undefined, - ), + renderItemMethod(option, i, handleItemClick), )} )} @@ -327,6 +296,7 @@ interface DropdownProps { children?: React.ReactElement; icon?: string; iconComponent?: IconProp; + iconClassName?: string; items?: Item[]; loading?: boolean; title?: string; @@ -340,11 +310,13 @@ interface DropdownProps { */ scrollKey?: string; status?: ImmutableMap; + needsStatusRefresh?: boolean; forceDropdown?: boolean; + className?: string; 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; } @@ -355,6 +327,7 @@ export const Dropdown = ({ children, icon, iconComponent, + iconClassName, items, loading, title = 'Menu', @@ -363,7 +336,9 @@ export const Dropdown = ({ placement = 'bottom', offset = [5, 5], status, + needsStatusRefresh, forceDropdown = false, + className, renderItem, renderHeader, onOpen, @@ -382,6 +357,7 @@ export const Dropdown = ({ const prefetchAccountId = status ? status.getIn(['account', 'id']) : undefined; + const statusId = status?.get('id') as string | undefined; const handleClose = useCallback(() => { if (buttonRef.current) { @@ -399,7 +375,7 @@ export const Dropdown = ({ }, [dispatch, currentId]); const handleItemClick = useCallback( - (e: React.MouseEvent | React.KeyboardEvent) => { + (e: React.MouseEvent) => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = items?.[i]; @@ -420,10 +396,20 @@ export const Dropdown = ({ [handleClose, onItemClick, items], ); - const toggleDropdown = useCallback( - (e: React.MouseEvent | React.KeyboardEvent) => { - const { type } = e; + const isKeypressRef = useRef(false); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + isKeypressRef.current = true; + } + }, []); + const unsetIsKeypress = useCallback(() => { + isKeypressRef.current = false; + }, []); + + const toggleDropdown = useCallback( + (e: React.MouseEvent) => { if (open) { handleClose(); } else { @@ -436,6 +422,15 @@ export const Dropdown = ({ dispatch(fetchRelationships([prefetchAccountId])); } + if (needsStatusRefresh && statusId) { + dispatch( + fetchStatus(statusId, { + forceFetch: true, + alsoFetchContext: false, + }), + ); + } + if (isUserTouching() && !forceDropdown) { dispatch( openModal({ @@ -443,6 +438,7 @@ export const Dropdown = ({ modalProps: { actions: items, onClick: handleItemClick, + className, }, }), ); @@ -450,10 +446,11 @@ export const Dropdown = ({ dispatch( openDropdownMenu({ id: currentId, - keyboard: type !== 'click', + keyboard: isKeypressRef.current, scrollKey, }), ); + isKeypressRef.current = false; } } }, @@ -468,6 +465,9 @@ export const Dropdown = ({ items, forceDropdown, handleClose, + statusId, + needsStatusRefresh, + className, ], ); @@ -484,6 +484,9 @@ export const Dropdown = ({ const buttonProps = { disabled, onClick: toggleDropdown, + onKeyDown: handleKeyDown, + onKeyUp: unsetIsKeypress, + onBlur: unsetIsKeypress, 'aria-expanded': open, 'aria-controls': menuId, ref: buttonRef, @@ -498,6 +501,7 @@ export const Dropdown = ({ iconComponent={iconComponent} title={title} active={open} + className={iconClassName} {...buttonProps} /> ); @@ -518,7 +522,7 @@ export const Dropdown = ({ popperConfig={popperConfig} > {({ props, arrowProps, placement }) => ( -
+
{ + (item: HistoryItem, index: number, onClick: React.MouseEventHandler) => { const formattedDate = ( - + ); const formattedName = ( @@ -84,12 +71,14 @@ export const EditedTimestamp: React.FC<{ id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} + tagName='span' /> ) : ( ); @@ -98,7 +87,7 @@ export const EditedTimestamp: React.FC<{ className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at') as string} > - @@ -118,7 +107,7 @@ export const EditedTimestamp: React.FC<{ onItemClick={handleItemClick} forceDropdown > - diff --git a/app/javascript/flavours/glitch/components/emoji/context.tsx b/app/javascript/flavours/glitch/components/emoji/context.tsx new file mode 100644 index 00000000000000..57e061172d672b --- /dev/null +++ b/app/javascript/flavours/glitch/components/emoji/context.tsx @@ -0,0 +1,118 @@ +import type { + FC, + MouseEventHandler, + PropsWithChildren, + ReactNode, +} from 'react'; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize'; +import { useCustomEmojis } from '@/flavours/glitch/hooks/useCustomEmojis'; +import { autoPlayGif } from '@/flavours/glitch/initial_state'; +import { polymorphicForwardRef } from '@/types/polymorphic'; +import type { + CustomEmojiMapArg, + ExtraCustomEmojiMap, +} from 'flavours/glitch/features/emoji/types'; + +// Animation context +export const AnimateEmojiContext = createContext(null); + +// Polymorphic provider component +type AnimateEmojiProviderProps = Required & { + className?: string; +}; + +export const AnimateEmojiProvider = polymorphicForwardRef< + 'div', + AnimateEmojiProviderProps +>( + ( + { + children, + as: Wrapper = 'div', + className, + onMouseEnter, + onMouseLeave, + ...props + }, + ref, + ) => { + const [animate, setAnimate] = useState(autoPlayGif ?? false); + + const handleEnter: MouseEventHandler = useCallback( + (event) => { + onMouseEnter?.(event); + if (!autoPlayGif) { + setAnimate(true); + } + }, + [onMouseEnter], + ); + const handleLeave: MouseEventHandler = useCallback( + (event) => { + onMouseLeave?.(event); + if (!autoPlayGif) { + setAnimate(false); + } + }, + [onMouseLeave], + ); + + // If there's a parent context or GIFs autoplay, we don't need handlers. + const parentContext = useContext(AnimateEmojiContext); + if (parentContext !== null) { + return ( + + {children} + + ); + } + + return ( + + + {children} + + + ); + }, +); +AnimateEmojiProvider.displayName = 'AnimateEmojiProvider'; + +// Handle custom emoji +export const CustomEmojiContext = createContext({}); + +export const CustomEmojiProvider = ({ + children, + emojis: rawEmojis, +}: PropsWithChildren<{ emojis?: CustomEmojiMapArg | null }>) => { + const emojis = useMemo(() => cleanExtraEmojis(rawEmojis), [rawEmojis]); + if (!emojis) { + return children; + } + return ( + + {children} + + ); +}; + +export const LocalCustomEmojiProvider: FC<{ children: ReactNode }> = ({ + children, +}) => { + const emojis = useCustomEmojis(); + return {children}; +}; diff --git a/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx b/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx new file mode 100644 index 00000000000000..e7d1887aa9a3a8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx @@ -0,0 +1,58 @@ +import type { ComponentProps } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { customEmojiFactory } from '@/testing/factories'; + +import { CustomEmojiProvider } from './context'; +import { Emoji } from './index'; + +type EmojiProps = ComponentProps & { + style: 'auto' | 'native' | 'twemoji'; +}; + +const meta = { + title: 'Components/Emoji', + component: Emoji, + args: { + code: '🖤', + style: 'auto', + }, + argTypes: { + code: { + name: 'Emoji', + }, + style: { + control: { + type: 'select', + labels: { + auto: 'Auto', + native: 'Native', + twemoji: 'Twemoji', + }, + }, + options: ['auto', 'native', 'twemoji'], + name: 'Emoji Style', + reduxPath: 'meta.emoji_style', + }, + }, + render(args) { + return ( + + + + ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomEmoji: Story = { + args: { + code: ':custom:', + }, +}; diff --git a/app/javascript/flavours/glitch/components/emoji/html.tsx b/app/javascript/flavours/glitch/components/emoji/html.tsx new file mode 100644 index 00000000000000..5dd7b504d82bdf --- /dev/null +++ b/app/javascript/flavours/glitch/components/emoji/html.tsx @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; + +import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types'; +import type { + OnAttributeHandler, + OnElementHandler, +} from '@/flavours/glitch/utils/html'; +import { htmlStringToComponents } from '@/flavours/glitch/utils/html'; +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import { AnimateEmojiProvider, CustomEmojiProvider } from './context'; +import { textToEmojis } from './index'; + +export interface EmojiHTMLProps { + htmlString: string; + extraEmojis?: CustomEmojiMapArg; + className?: string; + onElement?: OnElementHandler; + onAttribute?: OnAttributeHandler; +} + +export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( + ({ extraEmojis, htmlString, onElement, onAttribute, ...props }, ref) => { + const contents = useMemo( + () => + htmlStringToComponents(htmlString, { + onText: textToEmojis, + onElement, + onAttribute, + }), + [htmlString, onAttribute, onElement], + ); + + return ( + + + {contents} + + + ); + }, +); +EmojiHTML.displayName = 'EmojiHTML'; diff --git a/app/javascript/flavours/glitch/components/emoji/index.tsx b/app/javascript/flavours/glitch/components/emoji/index.tsx new file mode 100644 index 00000000000000..b2e5e5fb66c37b --- /dev/null +++ b/app/javascript/flavours/glitch/components/emoji/index.tsx @@ -0,0 +1,118 @@ +import type { FC } from 'react'; +import { useContext, useEffect, useState } from 'react'; + +import classNames from 'classnames'; + +import { + EMOJI_TYPE_CUSTOM, + EMOJI_TYPE_UNICODE, +} from '@/flavours/glitch/features/emoji/constants'; +import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode'; +import { + emojiToInversionClassName, + unicodeHexToUrl, +} from '@/flavours/glitch/features/emoji/normalize'; +import { + isStateLoaded, + loadEmojiDataToState, + shouldRenderImage, + stringToEmojiState, + tokenizeText, +} from '@/flavours/glitch/features/emoji/render'; + +import { AnimateEmojiContext, CustomEmojiContext } from './context'; + +interface EmojiProps { + code: string; + showFallback?: boolean; + showLoading?: boolean; +} + +export const Emoji: FC = ({ + code, + showFallback = true, + showLoading = true, +}) => { + const customEmoji = useContext(CustomEmojiContext); + + // First, set the emoji state based on the input code. + const [state, setState] = useState(() => + stringToEmojiState(code, customEmoji), + ); + + // If we don't have data, then load emoji data asynchronously. + const appState = useEmojiAppState(); + useEffect(() => { + if (state !== null) { + void loadEmojiDataToState(state, appState.currentLocale).then(setState); + } + }, [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. + if (!state) { + return fallback; + } + + if (!isStateLoaded(state)) { + if (showLoading) { + return ; + } + 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 ( + {shortcode} + ); + } + + const src = unicodeHexToUrl({ + unicodeHex: state.code, + ...appState, + }); + + return ( + {state.data.unicode} + ); +}; + +/** + * Takes a text string and converts it to an array of React nodes. + * @param text The text to be tokenized and converted. + */ +export function textToEmojis(text: string) { + return tokenizeText(text).map((token, index) => { + if (typeof token === 'string') { + return token; + } + return ; + }); +} diff --git a/app/javascript/flavours/glitch/components/emoji/picker_button.tsx b/app/javascript/flavours/glitch/components/emoji/picker_button.tsx new file mode 100644 index 00000000000000..fcf1d241f88e95 --- /dev/null +++ b/app/javascript/flavours/glitch/components/emoji/picker_button.tsx @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import EmojiPickerDropdown from '@/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container'; + +export const EmojiPickerButton: FC<{ + onPick: (emoji: string) => void; + disabled?: boolean; +}> = ({ onPick, disabled }) => { + const handlePick = useCallback( + (emoji: unknown) => { + if (disabled) { + return; + } + if (typeof emoji === 'object' && emoji !== null) { + if ('native' in emoji && typeof emoji.native === 'string') { + onPick(emoji.native); + } else if ( + 'shortcode' in emoji && + typeof emoji.shortcode === 'string' + ) { + onPick(`:${emoji.shortcode}:`); + } + } + }, + [disabled, onPick], + ); + return ; +}; diff --git a/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss b/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss new file mode 100644 index 00000000000000..868c6ea03b2817 --- /dev/null +++ b/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss @@ -0,0 +1,56 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 600px; + padding: 24px; + gap: 16px; + text-align: center; + color: var(--color-text-primary); +} + +.content { + max-width: 370px; + + > :where(svg, img) { + width: 200px; + aspect-ratio: 1; + object-fit: contain; + max-width: 100%; + margin-bottom: 16px; + } + + p { + margin-top: 8px; + font-size: 15px; + line-height: 1.4; + color: var(--color-text-secondary); + text-wrap: pretty; + } + + a { + color: var(--color-text-status-links); + } +} + +.heading { + font-size: 17px; + font-weight: 500; + text-wrap: balance; + line-height: 1.2; +} + +.errorImage { + width: 280px; + margin: -10% 0; +} + +[data-color-scheme='dark'] .defaultImage { + --color-skin-1: #3a3a50; + --color-skin-2: #67678e; + --color-skin-3: #44445f; + --color-outline: #21212c; + --color-shadow: #181820; + --color-highlight: #b2b1c8; +} diff --git a/app/javascript/flavours/glitch/components/empty_state/empty_state.stories.tsx b/app/javascript/flavours/glitch/components/empty_state/empty_state.stories.tsx new file mode 100644 index 00000000000000..c1faaf6f399198 --- /dev/null +++ b/app/javascript/flavours/glitch/components/empty_state/empty_state.stories.tsx @@ -0,0 +1,60 @@ +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 Error: Story = { + args: { + image: 'error', + title: 'Error', + message: 'Something went wrong loading the page.', + }, +}; + +export const WithAction: Story = { + args: { + ...Default.args, + // eslint-disable-next-line react/jsx-no-bind + children: , + }, +}; + +export const WithoutImage: Story = { + args: { + ...Default.args, + image: null, + }, +}; + +export const WithoutMessage: Story = { + args: { + ...Default.args, + message: undefined, + }, +}; diff --git a/app/javascript/flavours/glitch/components/empty_state/index.tsx b/app/javascript/flavours/glitch/components/empty_state/index.tsx new file mode 100644 index 00000000000000..3f0738ce5a5596 --- /dev/null +++ b/app/javascript/flavours/glitch/components/empty_state/index.tsx @@ -0,0 +1,56 @@ +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ElephantImage from '@/images/elephant_ui.svg?react'; + +import { GIF } from '../gif'; + +import classes from './empty_state.module.scss'; + +const images = { + default: , + error: ( + + ), +}; + +/** + * Simple empty state component with a neutral default title and customisable message. + * + * Action buttons can be passed as `children`. + */ + +export const EmptyState: React.FC<{ + image?: keyof typeof images | React.ReactElement | null; + title?: React.ReactNode; + message?: React.ReactNode; + children?: React.ReactNode; + headingLevel?: 'h2' | 'h3' | 'h4'; + className?: string; +}> = ({ + image = 'default', + title = ( + + ), + message, + children, + headingLevel: Heading = 'h2', + className, +}) => { + const imageToRender = typeof image === 'string' ? images[image] : image; + + return ( +
+ {(title || message || imageToRender) && ( +
+ {imageToRender} + {!!title && {title}} + {!!message &&

{message}

} +
+ )} + + {children} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/error_boundary.jsx b/app/javascript/flavours/glitch/components/error_boundary.jsx index 82ea7dc89f7f7f..82de329e79c33e 100644 --- a/app/javascript/flavours/glitch/components/error_boundary.jsx +++ b/app/javascript/flavours/glitch/components/error_boundary.jsx @@ -3,7 +3,7 @@ import { PureComponent } from 'react'; import { FormattedMessage } from 'react-intl'; -import { Helmet } from 'react-helmet'; +import { Helmet } from '@unhead/react/helmet'; import StackTrace from 'stacktrace-js'; diff --git a/app/javascript/flavours/glitch/components/exit_animation_wrapper.tsx b/app/javascript/flavours/glitch/components/exit_animation_wrapper.tsx index ab0642b8b2337c..4339068565aa14 100644 --- a/app/javascript/flavours/glitch/components/exit_animation_wrapper.tsx +++ b/app/javascript/flavours/glitch/components/exit_animation_wrapper.tsx @@ -26,23 +26,24 @@ export const ExitAnimationWrapper: React.FC<{ * Render prop that provides the nested component with the `delayedIsActive` flag */ children: (delayedIsActive: boolean) => React.ReactNode; -}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { - const [delayedIsActive, setDelayedIsActive] = useState(false); +}> = ({ isActive, delayMs = 500, withEntryDelay, children }) => { + const [delayedIsActive, setDelayedIsActive] = useState( + isActive && !withEntryDelay, + ); useEffect(() => { - if (isActive && !withEntryDelay) { - setDelayedIsActive(true); + const withDelay = !isActive || withEntryDelay; - return () => ''; - } else { - const timeout = setTimeout(() => { + const timeout = setTimeout( + () => { setDelayedIsActive(isActive); - }, delayMs); + }, + withDelay ? delayMs : 0, + ); - return () => { - clearTimeout(timeout); - }; - } + return () => { + clearTimeout(timeout); + }; }, [isActive, delayMs, withEntryDelay]); if (!isActive && !delayedIsActive) { diff --git a/app/javascript/flavours/glitch/components/familiar_followers/index.tsx b/app/javascript/flavours/glitch/components/familiar_followers/index.tsx new file mode 100644 index 00000000000000..2527389d28242f --- /dev/null +++ b/app/javascript/flavours/glitch/components/familiar_followers/index.tsx @@ -0,0 +1,81 @@ +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { Avatar } from '@/flavours/glitch/components/avatar'; +import { AvatarGroup } from '@/flavours/glitch/components/avatar_group'; +import { LinkedDisplayName } from '@/flavours/glitch/components/display_name'; +import type { Account } from '@/flavours/glitch/models/account'; + +import classes from './styles.module.scss'; +import { useFetchFamiliarFollowers } from './use_fetch_familiar_followers'; + +const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({ + familiarFollowers, +}) => { + const messageData = { + name1: ( + + ), + name2: ( + + ), + othersCount: familiarFollowers.length - 2, + }; + + if (familiarFollowers.length === 1) { + return ( + + ); + } else if (familiarFollowers.length === 2) { + return ( + + ); + } else { + return ( + + ); + } +}; + +export const FamiliarFollowers: React.FC<{ + accountId: string; + className?: string; +}> = ({ accountId, className }) => { + const { familiarFollowers, isLoading } = useFetchFamiliarFollowers({ + accountId, + }); + + if (isLoading || familiarFollowers.length === 0) { + return null; + } + + return ( +
+ + {familiarFollowers.slice(0, 3).map((account) => ( + + ))} + + + + +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/familiar_followers/styles.module.scss b/app/javascript/flavours/glitch/components/familiar_followers/styles.module.scss new file mode 100644 index 00000000000000..f3a11f970f9b91 --- /dev/null +++ b/app/javascript/flavours/glitch/components/familiar_followers/styles.module.scss @@ -0,0 +1,11 @@ +.wrapper { + display: flex; + align-items: center; + gap: 10px; + + a:any-link { + font-weight: 500; + text-decoration: none; + color: var(--color-text-primary); + } +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/hooks/familiar_followers.ts b/app/javascript/flavours/glitch/components/familiar_followers/use_fetch_familiar_followers.ts similarity index 100% rename from app/javascript/flavours/glitch/features/account_timeline/hooks/familiar_followers.ts rename to app/javascript/flavours/glitch/components/familiar_followers/use_fetch_familiar_followers.ts diff --git a/app/javascript/flavours/glitch/components/featured_carousel.tsx b/app/javascript/flavours/glitch/components/featured_carousel.tsx index 120797f7c544dc..5f85f12d1f1612 100644 --- a/app/javascript/flavours/glitch/components/featured_carousel.tsx +++ b/app/javascript/flavours/glitch/components/featured_carousel.tsx @@ -1,38 +1,43 @@ -import type { ComponentPropsWithRef } from 'react'; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - useId, -} from 'react'; +import { useCallback, useEffect, useId } from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, FormattedMessage } from 'react-intl'; import type { Map as ImmutableMap } from 'immutable'; import { List as ImmutableList } from 'immutable'; -import type { AnimatedProps } from '@react-spring/web'; -import { animated, useSpring } from '@react-spring/web'; -import { useDrag } from '@use-gesture/react'; - import { expandAccountFeaturedTimeline } from '@/flavours/glitch/actions/timelines'; import { Icon } from '@/flavours/glitch/components/icon'; -import { IconButton } from '@/flavours/glitch/components/icon_button'; -import StatusContainer from '@/flavours/glitch/containers/status_container'; -import { usePrevious } from '@/flavours/glitch/hooks/usePrevious'; -import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; -import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; -import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from '@/flavours/glitch/store'; import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react'; +import { Carousel } from './carousel'; + +const pinnedStatusesSelector = createAppSelector( + [ + (state, accountId: string, tagged?: string) => + (state.timelines as ImmutableMap).getIn( + [`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], + ImmutableList(), + ) as ImmutableList, + ], + (items) => items.toArray().map((id) => ({ id })), +); + const messages = defineMessages({ - previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' }, - next: { id: 'featured_carousel.next', defaultMessage: 'Next' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, + current: { + id: 'featured_carousel.current', + defaultMessage: 'Post {current, number} / {max, number}', + }, slide: { id: 'featured_carousel.slide', - defaultMessage: '{index} of {total}', + defaultMessage: 'Post {current, number} of {max, number}', }, }); @@ -40,7 +45,6 @@ export const FeaturedCarousel: React.FC<{ accountId: string; tagged?: string; }> = ({ accountId, tagged }) => { - const intl = useIntl(); const accessibilityId = useId(); // Load pinned statuses @@ -50,180 +54,37 @@ export const FeaturedCarousel: React.FC<{ void dispatch(expandAccountFeaturedTimeline(accountId, { tagged })); } }, [accountId, dispatch, tagged]); - const pinnedStatuses = useAppSelector( - (state) => - (state.timelines as ImmutableMap).getIn( - [`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], - ImmutableList(), - ) as ImmutableList, + const pinnedStatuses = useAppSelector((state) => + pinnedStatusesSelector(state, accountId, tagged), ); - // Handle slide change - const [slideIndex, setSlideIndex] = useState(0); - const wrapperRef = useRef(null); - const handleSlideChange = useCallback( - (direction: number) => { - setSlideIndex((prev) => { - const max = pinnedStatuses.size - 1; - let newIndex = prev + direction; - if (newIndex < 0) { - newIndex = max; - } else if (newIndex > max) { - newIndex = 0; - } - const slide = wrapperRef.current?.children[newIndex]; - if (slide) { - setCurrentSlideHeight(slide.scrollHeight); - } - return newIndex; - }); - }, - [pinnedStatuses.size], + const renderSlide = useCallback( + ({ id }: { id: string }) => ( + + ), + [], ); - // Handle slide heights - const [currentSlideHeight, setCurrentSlideHeight] = useState( - wrapperRef.current?.scrollHeight ?? 0, - ); - const previousSlideHeight = usePrevious(currentSlideHeight); - const observerRef = useRef( - new ResizeObserver(() => { - handleSlideChange(0); - }), - ); - const wrapperStyles = useSpring({ - x: `-${slideIndex * 100}%`, - height: currentSlideHeight, - // Don't animate from zero to the height of the initial slide - immediate: !previousSlideHeight, - }); - useLayoutEffect(() => { - // Update slide height when the component mounts - if (currentSlideHeight === 0) { - handleSlideChange(0); - } - }, [currentSlideHeight, handleSlideChange]); - - // Handle swiping animations - const bind = useDrag(({ swipe: [swipeX] }) => { - handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide. - }); - const handlePrev = useCallback(() => { - handleSlideChange(-1); - }, [handleSlideChange]); - const handleNext = useCallback(() => { - handleSlideChange(1); - }, [handleSlideChange]); - - if (!accountId || pinnedStatuses.isEmpty()) { + if (!accountId || pinnedStatuses.length === 0) { return null; } return ( -
-
-

- - -

- {pinnedStatuses.size > 1 && ( - <> - - - - {(text) => {text}} - - {slideIndex + 1} / {pinnedStatuses.size} - - - - )} -
- - {pinnedStatuses.map((statusId, index) => ( - - ))} - -
- ); -}; - -interface FeaturedCarouselItemProps { - statusId: string; - active: boolean; - observer: ResizeObserver; -} - -const FeaturedCarouselItem: React.FC< - FeaturedCarouselItemProps & AnimatedProps> -> = ({ statusId, active, observer, ...props }) => { - const handleRef = useCallback( - (instance: HTMLDivElement | null) => { - if (instance) { - observer.observe(instance); - } - }, - [observer], - ); - - return ( - - - +

+ + +

+ ); }; diff --git a/app/javascript/flavours/glitch/components/follow_button.tsx b/app/javascript/flavours/glitch/components/follow_button.tsx index e574b43b2521aa..477910b971adf8 100644 --- a/app/javascript/flavours/glitch/components/follow_button.tsx +++ b/app/javascript/flavours/glitch/components/follow_button.tsx @@ -3,11 +3,13 @@ import { useCallback, useEffect } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; +import { Link } from 'react-router-dom'; import { useIdentity } from '@/flavours/glitch/identity_context'; import { fetchRelationships, followAccount, + unmuteAccount, } from 'flavours/glitch/actions/accounts'; import { openModal } from 'flavours/glitch/actions/modal'; import { Button } from 'flavours/glitch/components/button'; @@ -15,17 +17,57 @@ import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { me } from 'flavours/glitch/initial_state'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; -const messages = defineMessages({ +import { useBreakpoint } from '../features/ui/hooks/useBreakpoint'; + +const longMessages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, + unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, + followRequest: { + id: 'account.follow_request', + defaultMessage: 'Request to follow', + }, + followRequestCancel: { + id: 'account.follow_request_cancel', + defaultMessage: 'Cancel request', + }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); +const shortMessages = { + ...longMessages, // Align type signature of shortMessages and longMessages + ...defineMessages({ + followBack: { + id: 'account.follow_back_short', + defaultMessage: 'Follow back', + }, + followRequest: { + id: 'account.follow_request_short', + defaultMessage: 'Request', + }, + followRequestCancel: { + id: 'account.follow_request_cancel_short', + defaultMessage: 'Cancel', + }, + editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' }, + }), +}; + export const FollowButton: React.FC<{ accountId?: string; compact?: boolean; -}> = ({ accountId, compact }) => { + labelLength?: 'auto' | 'short' | 'long'; + className?: string; + withUnmute?: boolean; +}> = ({ + accountId, + compact, + labelLength = 'auto', + className, + withUnmute = true, +}) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { signedIn } = useIdentity(); @@ -49,6 +91,7 @@ export const FollowButton: React.FC<{ openModal({ modalType: 'INTERACTION', modalProps: { + intent: 'follow', accountId: accountId, url: account?.url, }, @@ -60,58 +103,87 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; - } else if (account && (relationship.following || relationship.requested)) { + } 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( openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), ); + } else if (account && relationship.requested) { + dispatch( + openModal({ + modalType: 'CONFIRM_WITHDRAW_REQUEST', + modalProps: { account }, + }), + ); } else { dispatch(followAccount(accountId)); } - }, [dispatch, accountId, relationship, account, signedIn]); + }, [signedIn, relationship, accountId, withUnmute, account, dispatch]); + + const isNarrow = useBreakpoint('narrow'); + const useShortLabel = + labelLength === 'short' || (labelLength === 'auto' && isNarrow); + const messages = useShortLabel ? shortMessages : longMessages; + + const followMessage = account?.locked + ? messages.followRequest + : messages.follow; let label; + let disabled = + relationship?.blocked_by || account?.suspended || !!account?.moved; if (!signedIn) { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } else if (accountId === me) { label = intl.formatMessage(messages.edit_profile); } else if (!relationship) { label = ; - } else if (relationship.following || relationship.requested) { + } else if (relationship.muting && withUnmute) { + label = intl.formatMessage(messages.unmute); + disabled = false; + } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); - } else if (relationship.followed_by) { + disabled = false; + } else if (relationship.blocking) { + label = intl.formatMessage(messages.unblock); + disabled = false; + } else if (relationship.requested) { + label = intl.formatMessage(messages.followRequestCancel); + disabled = false; + } else if (relationship.followed_by && !account?.locked) { label = intl.formatMessage(messages.followBack); } else { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } if (accountId === me) { + const buttonClasses = classNames(className, 'button button-secondary', { + 'button--compact': compact, + }); + return ( - + {label} - + ); } return ( diff --git a/app/javascript/flavours/glitch/components/form_fields/checkbox.module.scss b/app/javascript/flavours/glitch/components/form_fields/checkbox.module.scss new file mode 100644 index 00000000000000..8f4ab99a5c6623 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/form_fields/checkbox_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx new file mode 100644 index 00000000000000..16b3a53f0baff2 --- /dev/null +++ b/app/javascript/flavours/glitch/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, + status: 'error', + }, +}; + +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/flavours/glitch/components/form_fields/checkbox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.tsx new file mode 100644 index 00000000000000..c08b81ca36d7f5 --- /dev/null +++ b/app/javascript/flavours/glitch/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, status, 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/flavours/glitch/components/form_fields/combobox.module.scss b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss new file mode 100644 index 00000000000000..e64c5e679e9f6e --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss @@ -0,0 +1,101 @@ +.wrapper { + position: relative; +} + +.input { + padding-inline-end: 45px; +} + +.menuButton { + position: absolute; + inset-inline-end: 1px; + top: 1px; + padding: 7px; + + &::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; + max-height: min(320px, 50dvh); + border-radius: 4px; + color: var(--color-text-primary); + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + box-shadow: var(--dropdown-shadow); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-gutter: stable; + overscroll-behavior-y: contain; +} + +.groupTitle { + padding: 8px 16px 4px; + font-size: 13px; + font-weight: bold; + text-transform: uppercase; +} + +.menuItem { + display: flex; + align-items: center; + padding: 8px 12px; + margin-inline: 4px; + gap: 12px; + font-size: 14px; + line-height: 20px; + border-radius: 4px; + color: var(--color-text-primary); + cursor: pointer; + user-select: none; + + &:first-child { + margin-top: 4px; + } + + &:last-child { + margin-bottom: 4px; + } + + &[data-highlighted='true'] { + background: var(--color-bg-overlay-highlight); + } + + &[aria-disabled='true'] { + color: var(--color-text-disabled); + cursor: not-allowed; + } +} + +.emptyMessage { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + padding: 16px; + font-size: 13px; +} + +.loadingIndicator { + --spinner-size: 20px; + + position: relative; + display: block; + width: var(--spinner-size); + height: var(--spinner-size); + overflow: hidden; + + & :global(.circular-progress) { + width: var(--spinner-size); + height: var(--spinner-size); + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx new file mode 100644 index 00000000000000..795cba727a6f36 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx @@ -0,0 +1,123 @@ +import { useCallback, useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ComboboxField, ComboboxMenuItem } from './combobox_field'; + +interface Fruit { + id: string; + name: string; + type: 'citrus' | 'berryish' | 'seedy' | 'stony' | 'longish' | 'chonky'; + disabled?: boolean; +} + +const ComboboxDemo: React.FC<{ withGroups?: boolean }> = ({ withGroups }) => { + const [searchValue, setSearchValue] = useState(''); + + const items: Fruit[] = [ + { id: '1', name: 'Apple', type: 'seedy' }, + { id: '2', name: 'Banana', type: 'longish' }, + { id: '3', name: 'Cherry', type: 'berryish', disabled: true }, + { id: '4', name: 'Date', type: 'stony' }, + { id: '5', name: 'Fig', type: 'seedy', disabled: true }, + { id: '6', name: 'Grape', type: 'berryish' }, + { id: '7', name: 'Honeydew', type: 'chonky' }, + { id: '8', name: 'Kiwi', type: 'seedy' }, + { id: '9', name: 'Lemon', type: 'citrus' }, + { id: '10', name: 'Mango', type: 'stony' }, + { id: '11', name: 'Nectarine', type: 'stony' }, + { id: '12', name: 'Orange', type: 'citrus' }, + { id: '13', name: 'Papaya', type: 'seedy' }, + { id: '14', name: 'Quince', type: 'seedy' }, + { id: '15', name: 'Raspberry', type: 'berryish' }, + { id: '16', name: 'Strawberry', type: 'berryish' }, + { id: '17', name: 'Tangerine', type: 'citrus' }, + { id: '19', name: 'Vanilla bean', type: 'longish' }, + { id: '20', name: 'Watermelon', type: 'chonky' }, + { id: '22', name: 'Yellow Passion Fruit', type: 'seedy' }, + { id: '23', name: 'Zucchini', type: 'longish' }, + { id: '24', name: 'Cantaloupe', type: 'chonky' }, + { id: '25', name: 'Blackberry', type: 'berryish' }, + { id: '26', name: 'Persimmon', type: 'seedy' }, + { id: '27', name: 'Lychee', type: 'berryish' }, + { id: '28', name: 'Dragon Fruit', type: 'seedy' }, + { id: '29', name: 'Passion Fruit', type: 'seedy' }, + { id: '30', name: 'Starfruit', type: 'seedy' }, + ]; + + 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; + + const groupedResults = withGroups + ? Object.groupBy(results, (item) => item.type) + : results; + + return ( + + ); +}; + +const meta = { + title: 'Components/Form Fields/ComboboxField', + component: ComboboxField, + subcomponents: { ComboboxMenuItem }, + render: () => , +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// These args are just used to keep TS happy, +// they're not passed to `ComboboxDemo` +const dummyArgs = { + label: '', + value: '', + onChange: () => undefined, + items: [], + getItemId: () => '', + renderItem: () => <>Nothing, + onSelectItem: () => undefined, +}; + +export const Simple: Story = { + args: dummyArgs, +}; + +export const WithGroups: Story = { + render: () => , + args: dummyArgs, +}; diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx new file mode 100644 index 00000000000000..b856c4b32d858f --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx @@ -0,0 +1,692 @@ +import { + createContext, + forwardRef, + useCallback, + useContext, + useId, + useMemo, + 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 SearchIcon from '@/material-icons/400-24px/search.svg?react'; +import { matchWidth } from 'flavours/glitch/components/dropdown/utils'; +import { IconButton } from 'flavours/glitch/components/icon_button'; +import { useOnClickOutside } from 'flavours/glitch/hooks/useOnClickOutside'; + +import { LoadingIndicator } from '../loading_indicator'; + +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'; +import type { TextInputProps } from './text_input_field'; + +interface ComboboxItem { + id: string; +} + +export interface ComboboxItemState { + isSelected: boolean; + isDisabled: boolean; +} + +interface ComboboxProps< + Item extends ComboboxItem, + GroupKey extends string, +> extends Omit { + /** + * The value of the combobox's text input + */ + value: string; + /** + * Change handler for the text input field + */ + onChange: React.ChangeEventHandler; + /** + * Set this to true when the list of options is dynamic and currently loading. + * Causes a loading indicator to be displayed inside of the dropdown menu. + */ + isLoading?: boolean; + /** + * The set of options/suggestions that should be rendered in the dropdown menu, + * optionally separated into groups by providing an object + */ + items: Item[] | Partial>; + /** + * A function that must return a unique id for each option passed via `items` + */ + getItemId?: (item: Item) => string; + /** + * Providing this function turns the combobox into a multi-select box that assumes + * multiple options to be selectable. Single-selection is handled automatically. + */ + getIsItemSelected?: (item: Item) => boolean; + /** + * Use this function to mark items as disabled, if needed + */ + getIsItemDisabled?: (item: Item) => boolean; + /** + * Customise the rendering of each option. + * The rendered content must not contain other interactive content! + */ + renderItem: ( + item: Item, + state: ComboboxItemState, + ) => React.ReactElement | string; + /** + * Customise the rendering of group titles. + * The `titleId` must be attached to the element that provides the + * accessible name for the group. + * Return `null` to omit rendering the group title. + */ + renderGroupTitle?: ( + groupKey: GroupKey, + titleId: string, + ) => React.ReactElement | null; + /** + * The main selection handler, called when an option is selected or deselected. + */ + onSelectItem: (item: Item) => void; + /** + * Icon to be displayed in the text input + */ + icon?: TextInputProps['icon'] | null; + /** + * Set to true to open as soon as there is focus + */ + openOnFocus?: boolean; + /** + * Set to false to keep the menu open when an item is selected + */ + closeOnSelect?: boolean; + /** + * Prevent the menu from opening, e.g. to prevent the empty state from showing + */ + suppressMenu?: boolean; +} + +interface Props + extends ComboboxProps, CommonFieldWrapperProps {} + +interface ComboboxItemPropsContext { + role: 'option'; + 'data-highlighted': boolean; + 'aria-selected': boolean; + 'aria-disabled': boolean; + 'data-item-id': string; + onMouseEnter: React.MouseEventHandler; + onClick: React.MouseEventHandler; +} + +const ComboboxItemPropsContext = createContext( + null, +); + +export function useComboboxItemProps() { + const context = useContext(ComboboxItemPropsContext); + + if (context === null) { + throw new Error( + 'useComboboxItemProps must be used within a Combobox component', + ); + } + + return context; +} + +export const ComboboxMenuItem: React.FC<{ + className?: string; + children: React.ReactNode; +}> = ({ className, children }) => { + const props = useComboboxItemProps(); + return ( +
  • + {children} +
  • + ); +}; + +export const ComboboxMenuGroupTitle: React.FC< + React.ComponentPropsWithoutRef<'li'> +> = ({ className, children, ...otherProps }) => { + return ( +
  • + {children} +
  • + ); +}; + +/** + * The combobox field allows users to select one or more items + * by searching or filtering a large or dynamic list of options. + * + * It is an implementation of the [APG Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), + * with inspiration taken from Sarah Higley's extensive combobox + * [research & implementations](https://sarahmhigley.com/writing/select-your-poison/). + */ + +export const ComboboxFieldWithRef = < + Item extends ComboboxItem, + GroupKey extends string, +>( + { id, label, hint, status, 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 = (item) => item.id, + getIsItemDisabled, + getIsItemSelected, + disabled, + renderGroupTitle, + renderItem, + onSelectItem, + onFocus, + onChange, + onKeyDown, + openOnFocus = false, + closeOnSelect = true, + suppressMenu = false, + icon = SearchIcon, + className, + ...otherProps + }: ComboboxProps, + ref: React.ForwardedRef, +) => { + const intl = useIntl(); + const wrapperRef = useRef(null); + const inputRef = useRef(); + const popoverRef = useRef(null); + + const [highlightedItemId, setHighlightedItemId] = useState( + null, + ); + const [shouldMenuOpen, setShouldMenuOpen] = useState(false); + + const hasGroups = !Array.isArray(items); + const flatItems = useMemo( + () => + hasGroups + ? (Object.values(items) + .flat() + .filter((i) => !!i) as Item[]) + : items, + [hasGroups, items], + ); + + const statusMessage = useGetA11yStatusMessage({ + value, + isLoading, + itemCount: flatItems.length, + }); + const showStatusMessageInMenu = + !!statusMessage && value.length > 0 && flatItems.length === 0; + const hasMenuContent = + !disabled && + !suppressMenu && + (flatItems.length > 0 || showStatusMessageInMenu); + const isMenuOpen = shouldMenuOpen && hasMenuContent; + + const openMenu = useCallback(() => { + setShouldMenuOpen(true); + inputRef.current?.focus(); + }, []); + + const closeMenu = useCallback(() => { + setShouldMenuOpen(false); + }, []); + + const resetHighlight = useCallback(() => { + const firstItem = flatItems[0]; + const firstItemId = firstItem ? getItemId(firstItem) : null; + setHighlightedItemId(firstItemId); + }, [getItemId, flatItems]); + + const highlightItem = useCallback((id: string | null) => { + setHighlightedItemId(id); + if (id) { + const itemElement = popoverRef.current?.querySelector( + `[data-item-id='${id}']`, + ); + if (itemElement && popoverRef.current) { + scrollItemIntoView(itemElement, popoverRef.current); + } + } + }, []); + + const handleFocus: React.FocusEventHandler = useCallback( + (e) => { + if (openOnFocus) { + setShouldMenuOpen(true); + } + onFocus?.(e); + }, + [onFocus, openOnFocus], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e); + resetHighlight(); + setShouldMenuOpen(!!e.target.value); + }, + [onChange, resetHighlight], + ); + + const handleItemMouseEnter = useCallback( + (e: React.MouseEvent) => { + const { itemId } = e.currentTarget.dataset; + if (itemId) { + highlightItem(itemId); + } + }, + [highlightItem], + ); + + const selectItem = useCallback( + (itemId: string | null) => { + const item = flatItems.find((item) => item.id === itemId); + if (item) { + const isDisabled = getIsItemDisabled?.(item) ?? false; + if (!isDisabled) { + onSelectItem(item); + + if (closeOnSelect) { + closeMenu(); + } + } + } + inputRef.current?.focus(); + }, + [closeMenu, closeOnSelect, getIsItemDisabled, flatItems, 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 (flatItems.length === 0) { + return; + } + const highlightedItemIndex = flatItems.findIndex( + (item) => getItemId(item) === highlightedItemId, + ); + if (highlightedItemIndex === -1) { + // If no item is highlighted yet, highlight the first or last + if (direction > 0) { + const firstItem = flatItems.at(0); + highlightItem(firstItem ? getItemId(firstItem) : null); + } else { + const lastItem = flatItems.at(-1); + highlightItem(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 >= flatItems.length) { + newIndex = 0; + } else if (newIndex < 0) { + newIndex = flatItems.length - 1; + } + + const newHighlightedItem = flatItems[newIndex]; + highlightItem( + newHighlightedItem ? getItemId(newHighlightedItem) : null, + ); + } + }, + [getItemId, highlightItem, highlightedItemId, flatItems], + ); + + 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(); + } + } + if (e.key === 'Escape') { + if (isMenuOpen) { + e.preventDefault(); + closeMenu(); + } + } + }, + [ + closeMenu, + isMenuOpen, + moveHighlight, + onKeyDown, + openMenu, + selectHighlightedItem, + ], + ); + + const renderItems = (items: Item[]) => + items.map((item) => { + const id = getItemId(item); + const isDisabled = getIsItemDisabled?.(item) ?? false; + const isHighlighted = id === highlightedItemId; + // If `getIsItemSelected` is defined, we assume 'multi-select' + // behaviour and don't set `aria-selected` based on highlight, + // but based on selected item state. + const isSelected = getIsItemSelected + ? getIsItemSelected(item) + : isHighlighted; + return ( + + {renderItem(item, { + isSelected, + isDisabled, + })} + + ); + }); + + 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 }) => ( +
    + + {hasGroups ? ( +
    + {(Object.keys(items) as GroupKey[]).map((groupKey) => { + const groupItems = items[groupKey]; + const groupTitleId = `${listId}-group-${groupKey}`; + const customGroupTitle = renderGroupTitle?.( + groupKey, + groupTitleId, + ); + const hasTitle = customGroupTitle !== null; + + if (!groupItems?.length) return null; + + return ( +
      + {hasTitle && + (customGroupTitle ?? ( + + {groupKey} + + ))} + {renderItems(groupItems)} +
    + ); + })} +
    + ) : ( +
      + {renderItems(items)} +
    + )} +
    +
    + )} +
    +
    + ); +}; + +// 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'; + +const StatusMessageWrapper: React.FC<{ + showStatus: boolean; + status: string; + isLoading: boolean; + children: React.ReactNode; +}> = ({ showStatus, status, isLoading, children }) => { + if (showStatus) { + return ( + + {isLoading && ( + + + + )} + {status} + + ); + } + + return children; +}; + +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 ''; +} + +const SCROLL_MARGIN = 6; + +function scrollItemIntoView(item: HTMLElement, scrollParent: HTMLElement) { + const itemTopEdge = item.offsetTop; + const itemBottomEdge = itemTopEdge + item.offsetHeight; + + // If item is above scroll area, scroll up + if (itemTopEdge < scrollParent.scrollTop) { + scrollParent.scrollTop = itemTopEdge - SCROLL_MARGIN; + } + // If item is below scroll area, scroll down + else if ( + itemBottomEdge > + scrollParent.scrollTop + scrollParent.offsetHeight + ) { + scrollParent.scrollTop = + itemBottomEdge - scrollParent.offsetHeight + SCROLL_MARGIN; + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/copy_link_field.module.scss b/app/javascript/flavours/glitch/components/form_fields/copy_link_field.module.scss new file mode 100644 index 00000000000000..68ee85c27ce660 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/copy_link_field.module.scss @@ -0,0 +1,14 @@ +.wrapper { + position: relative; +} + +.input { + padding-inline-end: 45px; +} + +.copyButton { + position: absolute; + inset-inline-end: 1px; + top: 1px; + padding: 7px; +} diff --git a/app/javascript/flavours/glitch/components/form_fields/copy_link_field.tsx b/app/javascript/flavours/glitch/components/form_fields/copy_link_field.tsx new file mode 100644 index 00000000000000..aa2b63392dbeb3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/copy_link_field.tsx @@ -0,0 +1,81 @@ +import { forwardRef, useCallback, useRef } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { CopyIconButton } from '@/flavours/glitch/components/copy_button'; + +import classes from './copy_link_field.module.scss'; +import { FormFieldWrapper } from './form_field_wrapper'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; +import { TextInput } from './text_input_field'; +import type { TextInputProps } from './text_input_field'; + +interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps { + value: string; +} + +/** + * A read-only text field with a button for copying the field value + */ + +export const CopyLinkField = forwardRef( + ( + { id, label, hint, status, value, required, className, ...otherProps }, + ref, + ) => { + const intl = useIntl(); + const inputRef = useRef(); + const handleFocus = useCallback(() => { + inputRef.current?.select(); + }, []); + + const mergeRefs = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + + return ( + + {(inputProps) => ( +
    + + +
    + )} +
    + ); + }, +); + +CopyLinkField.displayName = 'CopyLinkField'; diff --git a/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.module.scss b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.module.scss new file mode 100644 index 00000000000000..e214f9aed569a6 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.module.scss @@ -0,0 +1,24 @@ +.fieldWrapper div:has(:global(.emoji-picker-dropdown)) { + position: relative; + + > input, + > textarea { + padding-inline-end: 36px; + } + + > textarea { + min-height: 40px; // Button size with 8px margin + } +} + +.fieldWrapper :global(.emoji-picker-dropdown) { + position: absolute; + top: 8px; + right: 8px; + height: 24px; + z-index: 1; + + :global(.icon-button) { + color: var(--color-text-secondary); + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.stories.tsx new file mode 100644 index 00000000000000..05df009ecfc0f9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import type { EmojiInputProps } from './emoji_text_field'; +import { EmojiTextAreaField, EmojiTextInputField } from './emoji_text_field'; + +const meta = { + title: 'Components/Form Fields/EmojiTextInputField', + args: { + label: 'Label', + hint: 'Hint text', + value: 'Insert text with emoji', + }, + render({ value: initialValue = '', ...args }) { + const [value, setValue] = useState(initialValue); + return ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = {}; + +export const WithMaxLength: Story = { + args: { + counterMax: 20, + }, +}; + +export const WithRecommended: Story = { + args: { + counterMax: 20, + recommended: true, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; + +export const TextArea: Story = { + render(args) { + const [value, setValue] = useState('Insert text with emoji'); + return ( + + ); + }, +}; diff --git a/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx new file mode 100644 index 00000000000000..7e0b45abb4628c --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx @@ -0,0 +1,179 @@ +import type { + ChangeEvent, + ChangeEventHandler, + ComponentPropsWithoutRef, + FC, + ReactNode, + RefObject, +} from 'react'; +import { useCallback, useId, useRef } from 'react'; + +import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils'; +import type { OmitUnion } from '@/flavours/glitch/utils/types'; + +import { CharacterCounter } from '../character_counter'; +import { EmojiPickerButton } from '../emoji/picker_button'; + +import classes from './emoji_text_field.module.scss'; +import type { CommonFieldWrapperProps, InputProps } from './form_field_wrapper'; +import { FormFieldWrapper } from './form_field_wrapper'; +import { TextArea } from './text_area_field'; +import type { TextAreaProps } from './text_area_field'; +import { TextInput } from './text_input_field'; + +export type EmojiInputProps = { + value?: string; + onChange?: (newValue: string) => void; + counterMax?: number; + recommended?: boolean; +} & Omit; + +export const EmojiTextInputField: FC< + OmitUnion, EmojiInputProps> +> = ({ + onChange, + value, + label, + hint, + status, + maxLength, + counterMax = maxLength, + recommended, + disabled, + ...otherProps +}) => { + const inputRef = useRef(null); + + const wrapperProps = { + label, + hint, + status, + counterMax, + recommended, + disabled, + inputRef, + value, + onChange, + }; + + return ( + + {(inputProps) => ( + + )} + + ); +}; + +export const EmojiTextAreaField: FC< + OmitUnion, EmojiInputProps> +> = ({ + onChange, + value, + label, + maxLength, + counterMax = maxLength, + recommended, + disabled, + hint, + status, + ...otherProps +}) => { + const textareaRef = useRef(null); + + const wrapperProps = { + label, + hint, + status, + counterMax, + recommended, + disabled, + inputRef: textareaRef, + value, + onChange, + }; + + return ( + + {(inputProps) => ( +