diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3aa0bbf7da4ec3..ed8484f5b80b26 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,7 +9,7 @@ RUN /bin/bash --login -i -c "nvm install" # Install additional OS packages RUN apt-get update && \ export DEBIAN_FRONTEND=noninteractive && \ - apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev + apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg libvips42 libpam-dev # Disable download prompt for Corepack ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index ced5ecfe884c88..217415c528bf97 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -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/.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/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 808adc7de64f96..0c7ead1c15f1bd 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c1a1c99eb708e7..678a256b748e16 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', @@ -23,8 +22,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', diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 260730004cc774..84b729df434f74 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@v5 - name: Prepare env: @@ -100,7 +100,7 @@ jobs: - name: Upload digest if: ${{ inputs.push_to_images != '' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: # `hashFiles` is used to disambiguate between streaming and non-streaming images name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }} @@ -119,10 +119,10 @@ jobs: PUSH_TO_IMAGES: ${{ inputs.push_to_images }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: path: ${{ runner.temp }}/digests # `hashFiles` is used to disambiguate between streaming and non-streaming images diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index 6ec561d2fb589b..f934f2e550e855 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@v5 - 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..487e3fda300bc7 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@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..9cc49a7f79715f 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/bundlesize-compare.yml b/.github/workflows/bundlesize-compare.yml new file mode 100644 index 00000000000000..ab6d91f0b0aeed --- /dev/null +++ b/.github/workflows/bundlesize-compare.yml @@ -0,0 +1,73 @@ +name: Compare JS bundle size +on: + pull_request: + paths: + - 'app/javascript/**' + - 'vite.config.mts' + - 'package.json' + - 'yarn.lock' + - .github/workflows/bundlesize-compare.yml + +jobs: + build-head: + name: 'Build head' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@v5 + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@v5 + with: + name: head-stats + path: ./stats.json + if-no-files-found: error + + build-base: + name: 'Build base' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.base_ref }} + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@v5 + with: + name: base-stats + path: ./stats.json + if-no-files-found: error + + compare: + name: 'Compare base & head bundle sizes' + runs-on: ubuntu-latest + needs: [build-base, build-head] + permissions: + pull-requests: write + steps: + - uses: actions/download-artifact@v5 + + - uses: twk3/rollup-size-compare-action@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + current-stats-json-path: ./head-stats/stats.json + base-stats-json-path: ./base-stats/stats.json diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index c46090c1b565bc..9d500ffc44e2e6 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 4e6179bc7748db..634c2701865a8c 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -1,31 +1,51 @@ name: 'Chromatic' +permissions: + contents: read on: push: branches-ignore: - renovate/* - stable-* - paths: - - 'package.json' - - 'yarn.lock' - - '**/*.js' - - '**/*.jsx' - - '**/*.ts' - - '**/*.tsx' - - '**/*.css' - - '**/*.scss' - - '.github/workflows/chromatic.yml' jobs: + pathcheck: + name: Check for relevant changes + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.filter.outputs.src }} + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + src: + - 'package.json' + - 'yarn.lock' + - '**/*.js' + - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' + - '**/*.css' + - '**/*.scss' + - '.github/workflows/chromatic.yml' + chromatic: name: Run Chromatic runs-on: ubuntu-latest - if: github.repository == 'mastodon/mastodon' + needs: pathcheck + if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 + - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -33,9 +53,10 @@ jobs: run: yarn build-storybook - name: Run Chromatic - uses: chromaui/action@v12 + uses: chromaui/action@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..cf038ae4809b78 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,11 +31,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -48,7 +48,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -61,6 +61,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 28890321977479..6e7862b36867df 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@v5 - name: Increase Git http.postBuffer # This is needed due to a bug in Ubuntu's cURL version? diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 1fdd1e08b4fc53..c0c3374219d634 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@v5 - name: Increase Git http.postBuffer # This is needed due to a bug in Ubuntu's cURL version? diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index d6c542eb361b0e..6bbd931c532818 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: crowdin action uses: crowdin/github-action@v2 diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index c10f350a02ef28..6803686b4ff6c0 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index c1385bf789b0bc..51a78d679fcaa7 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - 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..d3452c9ffc295f 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 86e9af23e7efb1..c9ba1a13f17678 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - 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..e73617d85dace6 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 0699e6c9ef8193..b8e1cc89aaa632 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - 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..b1b94692f0c75c 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -72,7 +72,7 @@ jobs: BUNDLE_RETRY: 3 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - 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..316bf831b6fd1b 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -32,7 +32,7 @@ jobs: SECRET_KEY_BASE_DUMMY: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -65,7 +65,7 @@ jobs: run: | tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 if: matrix.mode == 'test' with: path: |- @@ -128,9 +128,9 @@ jobs: - '3.3' - '.ruby-version' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: path: './' name: ${{ github.sha }} @@ -173,93 +173,6 @@ jobs: 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 @@ -309,9 +222,9 @@ jobs: - '.ruby-version' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: path: './' name: ${{ github.sha }} @@ -350,14 +263,14 @@ jobs: - run: bin/rspec spec/system --tag streaming --tag js - name: Archive logs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: e2e-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: e2e-screenshots-${{ matrix.ruby-version }} @@ -447,9 +360,9 @@ jobs: search-image: opensearchproject/opensearch:2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: path: './' name: ${{ github.sha }} @@ -469,14 +382,14 @@ jobs: - run: bin/rspec --tag search - name: Archive logs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: test-search-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: test-search-screenshots 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/.nvmrc b/.nvmrc index 6e77d0a7496300..12fd1fc27773ea 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.19 +24.13 diff --git a/.prettierignore b/.prettierignore index 4e3b925f73873e..58e9b122adc11a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -95,6 +95,7 @@ AUTHORS.md # Ignore glitch-soc vendored CSS reset app/javascript/flavours/glitch/styles/reset.scss +app/javascript/flavours/glitch/styles_new/mastodon/reset.scss # Ignore win95 theme app/javascript/styles/win95.scss \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index 1cf8253024ccd6..7921bd0c892723 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.6 +3.4.8 diff --git a/.storybook/main.ts b/.storybook/main.ts index bb69f0c664957c..2f70c80dbfe00a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -27,11 +27,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..d2d34db80d5881 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -11,6 +11,11 @@ import type { Preview } from '@storybook/react-vite'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { action } from 'storybook/actions'; +import { + importCustomEmojiData, + importLegacyShortcodes, + importEmojiData, +} from '@/mastodon/features/emoji/loader'; import type { LocaleData } from '@/mastodon/locales'; import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; @@ -20,6 +25,7 @@ import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; // you can change the below to `/application.scss` import '../app/javascript/styles/mastodon-light.scss'; import './styles.css'; +import { modes } from './modes'; const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { query: { as: 'json' }, @@ -45,14 +51,47 @@ const preview: Preview = { dynamicTitle: true, }, }, + theme: { + description: 'Theme for the story', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: [{ value: 'light' }, { value: 'dark' }], + dynamicTitle: true, + }, + }, }, initialGlobals: { locale: 'en', + theme: 'light', }, decorators: [ - (Story, { parameters, globals }) => { + (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 +99,9 @@ const preview: Preview = { }, }, state as Record, + argsState, ); + const store = configureStore({ reducer, middleware(getDefaultMiddleware) { @@ -105,6 +146,13 @@ const preview: Preview = { ); }, + (Story, { globals }) => { + const theme = (globals.theme as string) || 'light'; + useEffect(() => { + document.body.setAttribute('data-color-scheme', theme); + }, [theme]); + return ; + }, (Story) => ( @@ -121,7 +169,12 @@ const preview: Preview = { ), ], - loaders: [mswLoader], + loaders: [ + mswLoader, + importCustomEmojiData, + importLegacyShortcodes, + ({ globals: { locale } }) => importEmojiData(locale as string), + ], parameters: { layout: 'centered', @@ -146,6 +199,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..02115fb4d994f0 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.1' 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.d.ts similarity index 54% rename from .storybook/storybook-addon-vitest.d.ts rename to .storybook/storybook.d.ts index 86852faca9f8f4..47624d1e9c6783 100644 --- a/.storybook/storybook-addon-vitest.d.ts +++ b/.storybook/storybook.d.ts @@ -1,7 +1,20 @@ // The addon package.json incorrectly exports types, so we need to override them here. + +import type { RootState } from '@/mastodon/store'; + // See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76 declare module '@storybook/addon-vitest/vitest-plugin' { export * from '@storybook/addon-vitest/dist/vitest-plugin/index'; } +type RootPathKeys = keyof RootState; + +declare module 'storybook/internal/csf' { + export interface InputType { + reduxPath?: + | `${RootPathKeys}.${string}` + | [RootPathKeys, ...(string | number)[]]; + } +} + export {}; diff --git a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch b/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch deleted file mode 100644 index 0b3f94d09ee83a..00000000000000 --- a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/lib/index.js b/lib/index.js -index 16ed6be8be8f555cc99096c2ff60954b42dc313d..d009c069770d066ad0db7ad02de1ea473a29334e 100644 ---- a/lib/index.js -+++ b/lib/index.js -@@ -99,7 +99,7 @@ function lodash(_ref) { - - var node = _ref3; - -- if ((0, _types.isModuleDeclaration)(node)) { -+ if ((0, _types.isImportDeclaration)(node) || (0, _types.isExportDeclaration)(node)) { - isModule = true; - break; - } diff --git a/AUTHORS.md b/AUTHORS.md index 78cc37a17b9350..5d243ed43b1b51 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -538,7 +538,7 @@ and provided thanks to the work of the following contributors: * [Drew Schuster](mailto:dtschust@gmail.com) * [Dryusdan](mailto:dryusdan@dryusdan.fr) * [Eai](mailto:eai@mizle.net) -* [Eashwar Ranganathan](mailto:eranganathan@lyft.com) +* [Eashwar Ranganathan](mailto:eashwar@eashwar.com) * [Ed Knutson](mailto:knutsoned@gmail.com) * [Elizabeth Martín Campos](mailto:me@elizabeth.sh) * [Elizabeth Myers](mailto:elizabeth@interlinked.me) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad88c51070c5a..cfbc450d74a19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,298 @@ All notable changes to this project will be documented in this file. +## [4.5.6] - 2026-02-03 + +### Security + +- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr)) + +### Changed + +- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire) + +### Fixed + +- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire) +- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS) +- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire) +- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire) +- Fix cross-server conversation tracking (#37559 by @ClearlyClaire) +- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable) + +## [4.5.5] - 2026-01-20 + +### Security + +- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g) +- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp) +- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3) +- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4) + +### Changed + +- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire) + +### Fixed + +- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire) +- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire) +- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire) +- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable) +- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec) +- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec) +- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec) +- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros) +- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable) +- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima) +- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion) + +## [4.5.4] - 2026-01-07 + +### Security + +- Fix SSRF protection bypass ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-xfrj-c749-jxxq)) +- Fix missing ownership check in severed relationships controller ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-ww85-x9cp-5v24)) + +### Changed + +- Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221 by @ClearlyClaire) + +### Fixed + +- Fix custom emojis not being rendered in profile fields (#37365 by @ClearlyClaire) +- Fix serialization of context pages (#37376 by @ClearlyClaire) +- Fix quotes with CWs but no text not having fallback link (#37361 by @ClearlyClaire) +- Fix outdated link target for “locked” warning (#37366 by @ClearlyClaire) +- Fix local custom emojis sometimes being rendered in remote posts (#37284 by @ChaosExAnima) +- Fix some assets not being loaded from configured CDN (#37310 by @ChaosExAnima) +- Fix notifications page error in Tor browser (#37285 by @diondiondion) +- Fix custom emojis not being displayed in CWs and fav/boost notifications (#37272 and #37306 by @ChaosExAnima and @ClearlyClaire) +- Fix default `Admin` role not including `view_feeds` permission (#37301 by @ClearlyClaire) +- Fix hashtag autocomplete replacing suggestion's first characters with input (#37281 by @ClearlyClaire) +- Fix mentions of domain-blocked users being processed (#37257 by @ClearlyClaire) + +## [4.5.3] - 2025-12-08 + +### Security + +- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8)) + +### Fixed + +- Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140 by @ClearlyClaire) +- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima) +- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire) +- Fix creation of duplicate conversations (#37108 by @oneiros) +- Fix extraneous `noreferrer` in external links (#37107 by @ChaosExAnima) +- Fix edge case error handling in some database migrations (#37079 by @ClearlyClaire) +- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire) +- Fix post navigation in single-column mode when Advanced UI is enabled (#37044 by @diondiondion) +- Fix `tootctl status remove` removing quoted posts and remote quotes of local posts (#37009 by @ClearlyClaire) +- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire) +- Fix compose autosuggest always lowercasing input token (#36995 by @ClearlyClaire) + +## [4.5.2] - 2025-11-20 + +### Changed + +- 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 diff --git a/Dockerfile b/Dockerfile index f2164ffd94102a..c06bc84a3395dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,10 +13,10 @@ ARG BASE_REGISTRY="docker.io" # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.4.6" -# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] +ARG RUBY_VERSION="3.4.8" +# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"] # renovate: datasource=node-version depName=node -ARG NODE_MAJOR_VERSION="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) @@ -70,8 +70,6 @@ ENV \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ # Optimize jemalloc 5.x performance MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \ - # Enable libvips, should not be changed - MASTODON_USE_LIBVIPS=true \ # Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs @@ -183,7 +181,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.17.2 +ARG VIPS_VERSION=8.18.0 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download @@ -208,12 +206,12 @@ FROM build AS ffmpeg # renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg ARG FFMPEG_VERSION=8.0 # 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} diff --git a/FEDERATION.md b/FEDERATION.md index 03ea5449de340b..0ac44afc3cd37c 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -48,3 +48,23 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques ### Additional documentation - [Mastodon documentation](https://docs.joinmastodon.org/) + +## Size limits + +Mastodon imposes a few hard limits on federated content. +These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accommodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons. +The following table summarizes those limits. + +| Limited property | Size limit | Consequence of exceeding the limit | +| ------------------------------------------------------------- | ---------- | ---------------------------------- | +| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** | +| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated | +| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated | +| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated | +| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** | +| Account display name (actor `name`) length | 2048 | Display name will be truncated | +| Account note (actor `summary`) length | 20kB | Account note will be truncated | +| Account `attributionDomains` | 256 | List will be truncated | +| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | +| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | +| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated | diff --git a/Gemfile b/Gemfile index 126d73f9cab1e3..9a0e1d609408fd 100644 --- a/Gemfile +++ b/Gemfile @@ -9,11 +9,11 @@ gem 'rails', '~> 8.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 'devise' gem 'devise-two-factor' group :pam_authentication, optional: true do @@ -40,7 +40,7 @@ gem 'net-ldap', '~> 0.18' gem 'omniauth', '~> 2.0' gem 'omniauth-cas', '~> 3.0.0.beta.1' gem 'omniauth_openid_connect', '~> 0.8.0' -gem 'omniauth-rails_csrf_protection', '~> 1.0' +gem 'omniauth-rails_csrf_protection', '~> 2.0' gem 'omniauth-saml', '~> 2.0' gem 'color_diff', '~> 0.1' @@ -55,7 +55,7 @@ gem 'hiredis-client' gem 'htmlentities', '~> 4.3' gem 'http', '~> 5.3.0' gem 'http_accept_language', '~> 2.1' -gem 'httplog', '~> 1.7.0', require: false +gem 'httplog', '~> 1.8.0', require: false gem 'i18n' gem 'idn-ruby', require: 'idn' gem 'inline_svg' @@ -71,7 +71,7 @@ gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' gem 'premailer-rails' -gem 'public_suffix', '~> 6.0' +gem 'public_suffix', '~> 7.0' gem 'pundit', '~> 2.3' gem 'rack-attack', '~> 6.6' gem 'rack-cors', require: 'rack/cors' @@ -105,20 +105,20 @@ gem 'prometheus_exporter', '~> 2.2', require: false gem 'opentelemetry-api', '~> 1.7.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.31.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false end @@ -138,7 +138,7 @@ group :test do # 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.57.1', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package # Used to reset the database between system tests gem 'database_cleaner-active_record' @@ -160,6 +160,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 @@ -184,7 +187,7 @@ group :development do gem 'letter_opener_web', '~> 3.0' # Security analysis CLI tools - gem 'brakeman', '~> 7.0', require: false + gem 'brakeman', '~> 8.0', require: false gem 'bundler-audit', '~> 0.9', require: false # Linter CLI for HAML files diff --git a/Gemfile.lock b/Gemfile.lock index 64ef3057d8a664..1c96aab8d67edf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (8.0.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + actioncable (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.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.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.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.0.3) + actionpack (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activesupport (= 8.0.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.0.3) + actionview (= 8.0.3) + activesupport (= 8.0.3) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,40 +40,40 @@ 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.0.3) + actionpack (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2.1) - activesupport (= 8.0.2.1) + actionview (8.0.3) + activesupport (= 8.0.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.0.3) + activesupport (= 8.0.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.0.3) + activesupport (= 8.0.3) + activerecord (8.0.3) + activemodel (= 8.0.3) + activesupport (= 8.0.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.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activesupport (= 8.0.3) marcel (~> 1.0) - activesupport (8.0.2.1) + activesupport (8.0.3) base64 benchmark (>= 0.3) bigdecimal @@ -86,27 +86,30 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotaterb (4.19.0) + annotaterb (4.21.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1135.0) - aws-sdk-core (3.215.1) + aws-partitions (1.1212.0) + aws-sdk-core (3.242.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.121.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.177.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -115,25 +118,25 @@ GEM rexml base64 (0.3.0) bcp47_spec (0.2.1) - bcrypt (3.1.20) - benchmark (0.4.1) + bcrypt (3.1.21) + 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) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.18.6) + bootsnap (1.20.1) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (8.0.2) racc browser (6.2.0) builder (3.3.0) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) capybara (3.40.0) addressable @@ -150,7 +153,7 @@ GEM playwright-ruby-client (>= 1.16.0) case_transform (0.2) activesupport - cbor (0.5.9.8) + cbor (0.5.10.1) cgi (0.4.2) charlock_holmes (0.7.9) chewy (7.6.0) @@ -162,13 +165,13 @@ GEM chunky_png (1.4.0) climate_control (1.2.0) cocoon (1.2.15) - color_diff (0.1) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + color_diff (0.2) + concurrent-ruby (1.3.6) + connection_pool (2.5.5) cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml crass (1.0.6) @@ -179,21 +182,21 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) - date (3.4.1) - debug (1.11.0) + date (3.5.1) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) - devise (4.9.4) + devise (5.0.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0) + railties (>= 7.0) responders warden (~> 1.2.3) - devise-two-factor (6.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) @@ -205,9 +208,9 @@ GEM domain_name (0.6.20240107) doorkeeper (5.8.2) railties (>= 5) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) - dry-cli (1.2.0) + dry-cli (1.3.0) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) elasticsearch-transport (= 7.17.11) @@ -224,28 +227,28 @@ GEM mail (~> 2.7) email_validator (2.2.4) activemodel - erb (5.0.2) + erb (6.0.1) erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo - excon (1.2.8) + excon (1.3.2) logger fabrication (3.0.0) - faker (3.5.2) + faker (3.6.0) i18n (>= 1.8.11, < 2) - faraday (2.13.4) + faraday (2.14.0) 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.2) + net-http (~> 0.5) fast_blank (1.0.1) fastimage (2.4.0) - ffi (1.17.2) + ffi (1.17.3) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake @@ -266,42 +269,44 @@ GEM 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.1) + 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.33.2) bigdecimal rake (>= 13) - googleapis-common-protos-types (1.20.0) - google-protobuf (>= 3.18, < 5.a) - haml (6.3.0) + googleapis-common-protos-types (1.22.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.69.0) haml (>= 5.0) 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.26.4) + redis-client (= 0.26.4) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -309,24 +314,26 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.8) + http-cookie (1.1.0) 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,8 +342,8 @@ 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) + irb (1.16.0) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -345,9 +352,9 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.13.2) + json (2.18.0) json-canonicalization (1.0.0) - json-jwt (1.16.7) + json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap base64 @@ -365,9 +372,9 @@ GEM json-ld-preloaded (3.3.2) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (6.0.0) + json-schema (6.1.0) addressable (~> 2.8) - bigdecimal (~> 3.1) + bigdecimal (>= 3.1, < 5) jsonapi-renderer (0.2.2) jwt (2.10.2) base64 @@ -383,7 +390,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) @@ -422,10 +429,11 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop @@ -438,16 +446,17 @@ 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.0127) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.8.0) - multi_json (1.17.0) + multi_json (1.19.1) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.9) + net-imap (0.6.2) date net-protocol net-ldap (0.20.0) @@ -459,22 +468,22 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.18.10) + nio4r (2.7.5) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.11) + oj (3.16.14) 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) @@ -496,102 +505,78 @@ GEM tzinfo validate_url webfinger (~> 2.0) - openssl (3.3.0) + openssl (3.3.2) openssl-signature_algorithm (1.3.0) openssl (> 2.0) opentelemetry-api (1.7.0) - opentelemetry-common (0.22.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.30.0) + opentelemetry-exporter-otlp (0.31.1) 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.3.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-processor (0.4.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.6.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.11.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.24.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.11.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.3.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.25.0) + 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.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.31.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.35.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-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-action_mailer (~> 0.6) + opentelemetry-instrumentation-action_pack (~> 0.15) + opentelemetry-instrumentation-action_view (~> 0.11) + opentelemetry-instrumentation-active_job (~> 0.10) + opentelemetry-instrumentation-active_record (~> 0.11) + opentelemetry-instrumentation-active_storage (~> 0.3) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23) + opentelemetry-instrumentation-redis (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.28.1) + opentelemetry-instrumentation-base (~> 0.25) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.9.0) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) @@ -603,19 +588,20 @@ GEM ox (2.14.23) bigdecimal (>= 3.0) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.1) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.6.2) + pg (1.6.3) pghero (3.7.0) activerecord (>= 7.1) - playwright-ruby-client (1.55.0) + playwright-ruby-client (1.57.1) + base64 concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) - pp (0.6.2) + pp (0.6.3) prettyprint premailer (1.27.0) addressable @@ -626,37 +612,37 @@ 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.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (7.0.3) + public_suffix (7.0.2) + puma (7.1.0) 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.4) + 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) @@ -667,22 +653,22 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) - rails (8.0.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.0.3) + actioncable (= 8.0.3) + actionmailbox (= 8.0.3) + actionmailer (= 8.0.3) + actionpack (= 8.0.3) + actiontext (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activemodel (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) bundler (>= 1.15.0) - railties (= 8.0.2.1) + railties (= 8.0.3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -690,19 +676,20 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (8.0.2) + rails-i18n (8.1.0) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) - railties (8.0.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + railties (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.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.3.1) rdf (3.3.4) bcp47_spec (~> 0.2) bigdecimal (~> 3.1, >= 3.1.5) @@ -712,43 +699,44 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.14.2) + rdoc (7.1.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-client (0.26.4) connection_pool - regexp_parser (2.11.2) - reline (0.6.2) + regexp_parser (2.11.3) + 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 (4.7.0) rpam2 (4.0.2) - rqrcode (3.1.0) + rqrcode (3.2.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.0) - rspec (3.13.1) + rqrcode_core (2.1.0) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (3.0.0) rspec-core (~> 3.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (8.0.2) @@ -764,8 +752,8 @@ GEM rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) - rspec-support (3.13.4) - rubocop (1.80.2) + rspec-support (3.13.6) + rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -773,32 +761,32 @@ GEM 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.0) parser (>= 3.3.7.2) - prism (~> 1.4) + prism (~> 1.7) rubocop-capybara (2.22.1) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-i18n (3.2.3) 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.34.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rspec (3.7.0) + rubocop-rspec (3.9.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-rspec_rails (2.31.0) + rubocop (~> 1.81) + rubocop-rspec_rails (2.32.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec (~> 3.5) @@ -808,14 +796,14 @@ GEM ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.5) + ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (3.1.0) + rubyzip (3.2.2) 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,9 +811,9 @@ 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) + shoulda-matchers (7.0.1) + activesupport (>= 7.1) + sidekiq (8.0.10) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) @@ -836,15 +824,16 @@ GEM sidekiq-scheduler (6.0.1) rufus-scheduler (~> 3.2) sidekiq (>= 7.3, < 9) - sidekiq-unique-jobs (8.0.11) + sidekiq-unique-jobs (8.0.13) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 7.0.0, < 9.0.0) thor (>= 1.0, < 3.0) - simple-navigation (4.4.0) + simple-navigation (4.4.1) activesupport (>= 2.3.2) - simple_form (5.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) @@ -855,10 +844,11 @@ GEM stackprof (0.2.27) starry (0.2.0) base64 - stoplight (5.3.8) + stoplight (5.7.0) + concurrent-ruby zeitwerk - stringio (3.1.7) - strong_migrations (2.5.0) + stringio (3.2.0) + strong_migrations (2.5.2) activerecord (>= 7.1) swd (2.0.3) activesupport (>= 3) @@ -871,14 +861,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.5.2) + thor (1.5.0) + tilt (2.7.0) + timeout (0.6.0) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) 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,20 +885,20 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.2) + tzinfo-data (1.2025.3) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (3.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.0.20) railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (3.9.2) @@ -918,23 +909,23 @@ 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.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) + webrick (1.9.2) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -943,7 +934,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.3) + zeitwerk (2.7.4) PLATFORMS ruby @@ -952,13 +943,13 @@ 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) better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.18.0) - brakeman (~> 7.0) + bootsnap + brakeman (~> 8.0) browser bundler-audit (~> 0.9) capybara (~> 3.39) @@ -973,7 +964,7 @@ DEPENDENCIES csv (~> 3.2) database_cleaner-active_record debug (~> 1.8) - devise (~> 4.9) + devise devise-two-factor devise_pam_authenticatable2 (~> 9.2) discard (~> 1.2) @@ -988,7 +979,7 @@ 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) @@ -996,7 +987,7 @@ DEPENDENCIES htmlentities (~> 4.3) http (~> 5.3.0) http_accept_language (~> 2.1) - httplog (~> 1.7.0) + httplog (~> 1.8.0) i18n i18n-tasks (~> 1.0) idn-ruby @@ -1024,34 +1015,34 @@ DEPENDENCIES oj (~> 3.14) omniauth (~> 2.0) omniauth-cas (~> 3.0.0.beta.1) - omniauth-rails_csrf_protection (~> 1.0) + omniauth-rails_csrf_protection (~> 2.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.8.0) opentelemetry-api (~> 1.7.0) - 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-exporter-otlp (~> 0.31.0) + opentelemetry-instrumentation-active_job (~> 0.10.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) + opentelemetry-instrumentation-excon (~> 0.27.0) + opentelemetry-instrumentation-faraday (~> 0.31.0) + opentelemetry-instrumentation-http (~> 0.28.0) + opentelemetry-instrumentation-http_client (~> 0.27.0) + opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-pg (~> 0.35.0) + opentelemetry-instrumentation-rack (~> 0.29.0) + opentelemetry-instrumentation-rails (~> 0.39.0) + opentelemetry-instrumentation-redis (~> 0.28.0) + opentelemetry-instrumentation-sidekiq (~> 0.28.0) opentelemetry-sdk (~> 1.4) ox (~> 2.14) parslet pg (~> 1.5) pghero - playwright-ruby-client (= 1.55.0) + playwright-ruby-client (= 1.57.1) premailer-rails prometheus_exporter (~> 2.2) propshaft - public_suffix (~> 6.0) + public_suffix (~> 7.0) puma (~> 7.0) pundit (~> 2.3) rack-attack (~> 6.6) @@ -1100,10 +1091,11 @@ DEPENDENCIES webauthn (~> 3.0) webmock (~> 3.18) webpush! + websocket-driver (~> 0.8) xorcist (~> 1.1) RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.8 BUNDLED WITH - 2.7.1 + 4.0.3 diff --git a/README.md b/README.md index ba800f59d5aed8..9cb0d3e61f8cee 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub] ### Requirements - **Ruby** 3.2+ -- **PostgreSQL** 13+ +- **PostgreSQL** 14+ - **Redis** 7.0+ - **Node.js** 20+ diff --git a/SECURITY.md b/SECURITY.md index 19f431fac5948e..e5790a66fa20c8 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.5.x | Yes | | 4.4.x | Yes | -| 4.3.x | Yes | -| 4.2.x | Until 2026-01-08 | -| < 4.2 | No | +| 4.3.x | Until 2026-05-06 | +| < 4.3 | No | diff --git a/Vagrantfile b/Vagrantfile index 0a34367024070a..a2c0b13b146031 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -29,7 +29,6 @@ sudo apt-get install \ libpq-dev \ libxml2-dev \ libxslt1-dev \ - imagemagick \ nodejs \ redis-server \ redis-tools \ diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 685b02ae6d99b1..990d08ca7f478a 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -71,6 +71,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/featured_collections_controller.rb b/app/controllers/activitypub/featured_collections_controller.rb new file mode 100644 index 00000000000000..872d03423d2172 --- /dev/null +++ b/app/controllers/activitypub/featured_collections_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class ActivityPub::FeaturedCollectionsController < ApplicationController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + PER_PAGE = 5 + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, if: -> { authorized_fetch_mode? } + before_action :set_collections + + skip_around_action :set_locale + skip_before_action :require_functional!, unless: :limited_federation_mode? + + def index + respond_to do |format| + format.json do + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) + + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_collections + authorize @account, :index_collections? + @collections = @account.collections.page(params[:page]).per(PER_PAGE) + rescue Mastodon::NotPermittedError + not_found + end + + def page_requested? + params[:page].present? + end + + def next_page_url + ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page) + end + + def prev_page_url + ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page) + end + + def collection_presenter + if page_requested? + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)), + type: :unordered, + size: @account.collections.count, + items: @collections, + part_of: ap_account_featured_collections_url(@account), + next: next_page_url, + prev: prev_page_url + ) + else + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account), + type: :unordered, + size: @account.collections.count, + first: ap_account_featured_collections_url(@account, page: 1) + ) + end + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 49cfc8ad1cbd8c..cf46bf21b5e44c 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -3,6 +3,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController include JsonLdHelper + before_action :skip_large_payload before_action :skip_unknown_actor_activity before_action :require_actor_signature! skip_before_action :authenticate_user! @@ -16,6 +17,10 @@ def create private + def skip_large_payload + head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE + end + def skip_unknown_actor_activity head 202 if unknown_affected_account? end @@ -39,7 +44,7 @@ def body return @body if defined?(@body) @body = request.body.read - @body.force_encoding('UTF-8') if @body.present? + @body.presence&.force_encoding('UTF-8') request.body.rewind if request.body.respond_to?(:rewind) diff --git a/app/controllers/activitypub/likes_controller.rb b/app/controllers/activitypub/likes_controller.rb index 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/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index fbef61810d42c6..55a03b9804cfaf 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -5,6 +5,15 @@ class CustomEmojisController < BaseController def index authorize :custom_emoji, :index? + # If filtering by local emojis, remove by_domain filter. + params.delete(:by_domain) if params[:local].present? + + # If filtering by domain, ensure remote filter is set. + if params[:by_domain].present? + params.delete(:local) + params[:remote] = '1' + end + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) @form = Form::CustomEmojiBatch.new end diff --git a/app/controllers/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/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/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb index 6d115631a2b2d8..b9b58b23d4434d 100644 --- a/app/controllers/api/v1/accounts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/notes_controller.rb @@ -9,9 +9,9 @@ class Api::V1::Accounts::NotesController < Api::BaseController def create if params[:comment].blank? - AccountNote.find_by(account: current_account, target_account: @account)&.destroy + current_account.account_notes.find_by(target_account: @account)&.destroy else - @note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account) + @note = current_account.account_notes.find_or_initialize_by(target_account: @account) @note.comment = params[:comment] @note.save! if @note.changed? end diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb index b1aee288dd8595..71a97e1d9a68b2 100644 --- a/app/controllers/api/v1/annual_reports_controller.rb +++ b/app/controllers/api/v1/annual_reports_controller.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class Api::V1::AnnualReportsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index - before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index + include AsyncRefreshesConcern + + before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, except: [:read, :generate] + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:read, :generate] before_action :require_user! - before_action :set_annual_report, except: :index + before_action :set_annual_report, only: [:show, :read] def index with_read_replica do @@ -28,6 +30,28 @@ def show relationships: @relationships end + def state + render json: { state: report_state } + end + + def generate + return render_empty unless year == AnnualReport.current_campaign + return render_empty if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year) + + async_refresh = AsyncRefresh.new(refresh_key) + + if async_refresh.running? + add_async_refresh_header(async_refresh, retry_seconds: 2) + return head 202 + end + + add_async_refresh_header(AsyncRefresh.create(refresh_key), retry_seconds: 2) + + GenerateAnnualReportWorker.perform_async(current_account.id, year) + + head 202 + end + def read @annual_report.view! render_empty @@ -35,7 +59,21 @@ def read private + def report_state + AnnualReport.new(current_account, year).state do |async_refresh| + add_async_refresh_header(async_refresh, retry_seconds: 2) + end + end + + def refresh_key + "wrapstodon:#{current_account.id}:#{year}" + end + + def year + params[:id]&.to_i + end + def set_annual_report - @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id]) + @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: year) end end diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 2833687a38cb1f..659e52bac48fba 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -17,7 +17,7 @@ def create def set_poll @poll = Poll.find(params[:poll_id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index b4c25476e8544b..bf30c178571e93 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -17,7 +17,7 @@ def show def set_poll @poll = Poll.find(params[:id]) authorize @poll.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses/base_controller.rb b/app/controllers/api/v1/statuses/base_controller.rb index 3f56b68bcf41f1..0c4c49a2c3ff26 100644 --- a/app/controllers/api/v1/statuses/base_controller.rb +++ b/app/controllers/api/v1/statuses/base_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController def set_status @status = Status.find(params[:status_id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index 109b12f467efe0..b4b976ac3c5ce6 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -23,7 +23,7 @@ def destroy bookmark&.destroy! render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false }) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index dbc75a03644dbb..17eeccdbe749f0 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -25,7 +25,7 @@ def destroy relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } }) render json: @status, serializer: REST::StatusSerializer, relationships: relationships - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/v1/statuses/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..32a5f71293d537 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -26,7 +26,7 @@ def destroy def distribute_add_activity! json = ActiveModelSerializers::SerializableResource.new( @status, - serializer: ActivityPub::AddSerializer, + serializer: ActivityPub::AddNoteSerializer, adapter: ActivityPub::Adapter ).as_json @@ -36,7 +36,7 @@ def distribute_add_activity! def distribute_remove_activity! json = ActiveModelSerializers::SerializableResource.new( @status, - serializer: ActivityPub::RemoveSerializer, + serializer: ActivityPub::RemoveNoteSerializer, adapter: ActivityPub::Adapter ).as_json diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb index 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..a2db011439b811 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -66,7 +66,7 @@ def context 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)) + add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true)) WorkerBatch.new.within do |batch| batch.connect(refresh_key, threshold: 1.0) @@ -93,6 +93,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 +108,7 @@ def update @status = Status.where(account: current_account).find(params[:id]) authorize @status, :update? - UpdateStatusService.new.call( - @status, - current_account.id, + update_options = { text: status_params[:status], media_ids: status_params[:media_ids], media_attributes: status_params[:media_attributes], @@ -117,9 +116,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 +130,11 @@ def destroy @status = Status.where(account: current_account).find(params[:id]) authorize @status, :destroy? + 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 +150,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 +162,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 +193,7 @@ def status_params :language, :scheduled_at, :content_type, + :local_only, allowed_mentions: [], media_ids: [], media_attributes: [ 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/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb new file mode 100644 index 00000000000000..3f1f10a3ceb4d2 --- /dev/null +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Api::V1Alpha::CollectionItemsController < Api::BaseController + include Authorization + + before_action :check_feature_enabled + + before_action -> { doorkeeper_authorize! :write, :'write:collections' } + + before_action :require_user! + + before_action :set_collection + before_action :set_account, only: [:create] + before_action :set_collection_item, only: [:destroy] + + after_action :verify_authorized + + def create + authorize @collection, :update? + authorize @account, :feature? + + @item = AddAccountToCollectionService.new.call(@collection, @account) + + render json: @item, serializer: REST::CollectionItemSerializer, adapter: :json + end + + def destroy + authorize @collection, :update? + + @collection_item.destroy + + head 200 + end + + private + + def set_collection + @collection = Collection.find(params[:collection_id]) + end + + def set_account + return render(json: { error: '`account_id` parameter is missing' }, status: 422) if params[:account_id].blank? + + @account = Account.find(params[:account_id]) + end + + def set_collection_item + @collection_item = @collection.collection_items.find(params[:id]) + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb new file mode 100644 index 00000000000000..792a072d32faa2 --- /dev/null +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class Api::V1Alpha::CollectionsController < Api::BaseController + include Authorization + + DEFAULT_COLLECTIONS_LIMIT = 40 + + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422 + end + + before_action :check_feature_enabled + + before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy] + + before_action :require_user!, only: [:create, :update, :destroy] + + before_action :set_account, only: [:index] + before_action :set_collections, only: [:index] + before_action :set_collection, only: [:show, :update, :destroy] + + after_action :insert_pagination_headers, only: [:index] + + after_action :verify_authorized + + def index + cache_if_unauthenticated! + authorize @account, :index_collections? + + render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json + rescue Mastodon::NotPermittedError + render json: { collections: [] } + end + + def show + cache_if_unauthenticated! + authorize @collection, :show? + + render json: @collection, serializer: REST::CollectionWithAccountsSerializer + end + + def create + authorize Collection, :create? + + @collection = CreateCollectionService.new.call(collection_creation_params, current_user.account) + + render json: @collection, serializer: REST::CollectionSerializer, adapter: :json + end + + def update + authorize @collection, :update? + + @collection.update!(collection_update_params) # TODO: Create a service for this to federate changes + + render json: @collection, serializer: REST::CollectionSerializer, adapter: :json + end + + def destroy + authorize @collection, :destroy? + + DeleteCollectionService.new.call(@collection) + + head 200 + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def set_collections + @collections = @account.collections + .with_tag + .order(created_at: :desc) + .offset(offset_param) + .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections = @collections.discoverable unless @account == current_account + end + + def set_collection + @collection = Collection.find(params[:id]) + end + + def collection_creation_params + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name, account_ids: []) + end + + def collection_update_params + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name) + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end + + def next_path + return unless records_continue? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def prev_path + return if offset_param.zero? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def records_continue? + ((offset_param * limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections.size) < @account.collections.size + end + + def offset_param + params[:offset].to_i + end +end diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index f82c1c50d79502..fba56b405864ef 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -30,7 +30,7 @@ def show def set_status @status = Status.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index ced68d39fc7504..2edd92dbc7be85 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -62,7 +62,7 @@ def update_session_with_subscription end def set_push_subscription - @push_subscription = ::Web::PushSubscription.find(params[:id]) + @push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id]) end def subscription_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 205ce614770197..7c4d3fb86c2def 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base helper_method :current_flavour helper_method :current_skin helper_method :current_theme + helper_method :color_scheme + helper_method :contrast helper_method :single_user_mode? helper_method :use_seamless_external_login? helper_method :sso_account_settings @@ -174,6 +176,25 @@ def current_session @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? end + def color_scheme + current = current_user&.setting_color_scheme + return current if current && current != 'auto' + + return 'dark' if current_skin.include?('default') || current_skin.include?('contrast') + return 'light' if current_skin.include?('light') + + 'auto' + end + + def contrast + current = current_user&.setting_contrast + return current if current && current != 'auto' + + return 'high' if current_skin.include?('contrast') + + 'auto' + end + def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 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_controller.rb b/app/controllers/auth/sessions_controller.rb index 182f242ae5b521..077f4d9db5c0ae 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -197,14 +197,14 @@ def second_factor_attempts_key(user) "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" end - def respond_to_on_destroy + def respond_to_on_destroy(**) respond_to do |format| format.json do render json: { redirect_to: after_sign_out_path_for(resource_name), }, status: 200 end - format.all { super } + format.all { super(**) } end end end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index 99eed018b070ed..03cad3e3175f03 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -21,7 +21,7 @@ def show def set_resource @resource = located_resource authorize(@resource, :show?) if @resource.is_a?(Status) - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb new file mode 100644 index 00000000000000..3e2ba714702d59 --- /dev/null +++ b/app/controllers/collections_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class CollectionsController < ApplicationController + include WebAppControllerConcern + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :set_collection + + skip_around_action :set_locale, if: -> { request.format == :json } + skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? + + def show + respond_to do |format| + # TODO: format.html + + format.json do + expires_in expiration_duration, public: true if public_fetch_mode? + render_with_cache json: @collection, content_type: 'application/activity+json', serializer: ActivityPub::FeaturedCollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def set_collection + @collection = @account.collections.find(params[:id]) + authorize @collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end + + def expiration_duration + recently_updated = @collection.updated_at > 15.minutes.ago + recently_updated ? 30.seconds : 5.minutes + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/concerns/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/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 2bdd3558643526..1e83ab9c69b6b7 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -72,10 +72,13 @@ def signed_request_actor rescue Mastodon::SignatureVerificationError => e fail_with! e.message rescue *Mastodon::HTTP_CONNECTION_ERRORS => e + @signature_verification_failure_code ||= 503 fail_with! "Failed to fetch remote data: #{e.message}" rescue Mastodon::UnexpectedResponseError + @signature_verification_failure_code ||= 503 fail_with! 'Failed to fetch remote data (got unexpected reply from server)' rescue Stoplight::Error::RedLight + @signature_verification_failure_code ||= 503 fail_with! 'Fetching attempt skipped because of recent connection failure' end diff --git a/app/controllers/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/media_controller.rb b/app/controllers/media_controller.rb index 9d10468e69330e..0590ea40270cd3 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -34,7 +34,7 @@ def set_media_attachment def verify_permitted_status! authorize @media_attachment.status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index d55b90ad88761c..267107b6272c1e 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -11,9 +11,7 @@ class MediaProxyController < ApplicationController before_action :authenticate_user!, if: :limited_federation_mode? before_action :set_media_attachment - rescue_from ActiveRecord::RecordInvalid, with: :not_found - rescue_from Mastodon::UnexpectedResponseError, with: :not_found - rescue_from Mastodon::NotPermittedError, with: :not_found + rescue_from ActiveRecord::RecordInvalid, Mastodon::NotPermittedError, Mastodon::UnexpectedResponseError, with: :not_found rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) def show diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bf7edbfdaf3cdb..aa8f131d682cdf 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -20,14 +20,8 @@ def store_current_location store_location_for(:user, request.url) end - def render_success - if skip_authorization? || (matching_token? && !truthy_param?('force_login')) - redirect_or_render authorize_response - elsif Doorkeeper.configuration.api_only - render json: pre_auth - else - render :new - end + def can_authorize_response? + !truthy_param?('force_login') && super end def truthy_param?(key) diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb index 817abebf62d1da..9371ebf7d0676a 100644 --- a/app/controllers/severed_relationships_controller.rb +++ b/app/controllers/severed_relationships_controller.rb @@ -26,7 +26,7 @@ def followers private def set_event - @event = AccountRelationshipSeveranceEvent.find(params[:id]) + @event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id]) end def following_data diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index af6bebf36fd753..be3641589fcd4b 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -29,7 +29,7 @@ def show end format.json do - expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? + expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode? render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter end end @@ -37,7 +37,7 @@ def show def activity expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? - render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + render_with_cache json: @status, content_type: 'application/activity+json', serializer: activity_serializer, adapter: ActivityPub::Adapter end def embed @@ -62,11 +62,15 @@ def set_link_headers def set_status @status = @account.statuses.find(params[:id]) authorize @status, :show? - rescue Mastodon::NotPermittedError + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError not_found end def redirect_to_original redirect_to(ActivityPub::TagManager.instance.url_for(@status.reblog), allow_other_host: true) if @status.reblog? end + + def activity_serializer + @status.reblog? ? ActivityPub::AnnounceNoteSerializer : ActivityPub::CreateNoteSerializer + end end diff --git a/app/controllers/wrapstodon_controller.rb b/app/controllers/wrapstodon_controller.rb new file mode 100644 index 00000000000000..b1fe521fb114d5 --- /dev/null +++ b/app/controllers/wrapstodon_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class WrapstodonController < ApplicationController + include WebAppControllerConcern + include Authorization + include AccountOwnedConcern + + vary_by 'Accept, Accept-Language, Cookie' + + before_action :set_generated_annual_report + + skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? + + def show + expires_in 10.minutes, public: true if current_account.nil? + end + + private + + def set_generated_annual_report + @generated_annual_report = GeneratedAnnualReport.find_by!(account: @account, year: params[:year], share_key: params[:share_key]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e759c7e3bbffc5..1dee3f96a8429e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -89,6 +89,12 @@ def title Rails.env.production? ? site_title : "#{site_title} (Dev)" end + def page_color_scheme + return content_for(:force_color_scheme) if content_for(:force_color_scheme) + + color_scheme + end + def label_for_scope(scope) safe_join [ tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), @@ -113,6 +119,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,7 +128,7 @@ def material_symbol(icon, attributes = {}) role: :img, data: attributes[:data] ), - ' ', + whitespace ? ' ' : '', ] ) end @@ -152,11 +159,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 +183,12 @@ def body_classes output.compact_blank.join(' ') end + def body_classes + output = [] + output << content_for(:body_classes) + output.compact_blank.join(' ') + end + def cdn_host Rails.configuration.action_controller.asset_host end diff --git a/app/helpers/database_helper.rb b/app/helpers/database_helper.rb index 62a26a0c2a05c8..f245d303d10ba9 100644 --- a/app/helpers/database_helper.rb +++ b/app/helpers/database_helper.rb @@ -2,7 +2,7 @@ module DatabaseHelper def replica_enabled? - ENV['REPLICA_DB_NAME'] || ENV.fetch('REPLICA_DATABASE_URL', nil) + ENV['REPLICA_DB_NAME'] || ENV['REPLICA_DB_HOST'] || ENV.fetch('REPLICA_DATABASE_URL', nil) end module_function :replica_enabled? diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 22a1c172de2c86..ec0d55788327d7 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module FiltersHelper + KEYWORDS_LIMIT = 5 + def filter_action_label(action) safe_join( [ @@ -9,4 +11,10 @@ def filter_action_label(action) ] ) end + + def filter_keywords(filter) + filter.keywords.map(&:keyword).take(KEYWORDS_LIMIT).tap do |list| + list << '…' if filter.keywords.size > KEYWORDS_LIMIT + end.join(', ') + end end diff --git a/app/helpers/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/languages_helper.rb b/app/helpers/languages_helper.rb index ddb6b79c8667b2..cbf5638ae4edd4 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, @@ -233,6 +233,7 @@ module LanguagesHelper 'es-AR': 'Español (Argentina)', 'es-MX': 'Español (México)', 'fr-CA': 'Français (Canadien)', + 'nan-TW': '臺語 (Hô-ló話)', 'pt-BR': 'Português (Brasil)', 'pt-PT': 'Português (Portugal)', 'sr-Latn': 'Srpski (latinica)', 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..7202dfd9ab4c1b 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -1,29 +1,42 @@ # 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' + # TODO: get rid of that when we retire the themes and perform the settings migration + theme = 'default' if %w(mastodon-light contrast system).include?(theme) + + vite_stylesheet_tag "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 @@ -53,8 +66,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..92b9d1d9177ddb 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,7 +62,7 @@ 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( @@ -84,7 +84,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,7 +112,7 @@ Rails.delegate(document, '.batch-table__select-all button', 'click', () => { } }); -Rails.delegate(document, batchCheckboxClassName, 'change', () => { +on('change', batchCheckboxClassName, () => { const checkAllElement = document.querySelector( 'input#batch_checkbox_all', ); @@ -139,14 +139,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 +162,11 @@ 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) => { +function onEnableBootstrapTimelineAccountsChange(target: HTMLInputElement) { const bootstrapTimelineAccountsField = document.querySelector( '#form_admin_settings_bootstrap_timeline_accounts', @@ -193,12 +188,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 +232,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 +244,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 +256,8 @@ async function mountReactComponent(element: Element) { const componentProps = JSON.parse(stringProps) as object; - const { default: AdminComponent } = await import( - '@/mastodon/containers/admin_component' - ); + const { default: AdminComponent } = + await import('@/mastodon/containers/admin_component'); const { default: Component } = (await import( `@/mastodon/components/admin/${componentName}.jsx` @@ -304,7 +292,7 @@ ready(() => { if (registrationMode) onChangeRegistrationMode(registrationMode); const checkAllElement = document.querySelector( - 'input#batch_checkbox_all', + '#batch_checkbox_all', ); if (checkAllElement) { const allCheckboxes = Array.from( @@ -317,7 +305,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 +329,7 @@ ready(() => { } }); - Rails.delegate(document, 'form', 'submit', ({ target }) => { + on('submit', 'form', ({ target }) => { if (target instanceof HTMLFormElement) target .querySelectorAll('input[type="datetime-local"]') @@ -362,6 +350,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..6e88eb8778068a 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -4,8 +4,8 @@ import { IntlMessageFormat } from 'intl-messageformat'; import type { 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'; @@ -175,10 +175,9 @@ function loaded() { }); } - Rails.delegate( - document, - 'input#user_account_attributes_username', + on( 'input', + 'input#user_account_attributes_username', throttle( ({ target }) => { if (!(target instanceof HTMLInputElement)) return; @@ -202,60 +201,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 +249,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 +298,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 +362,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< + HTMLInputElement | HTMLSelectElement + >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select') + .forEach((input) => { + setInputDisabled(input, !target.checked); + }); +}); const updateDefaultQuotePrivacyFromPrivacy = ( privacySelect: EventTarget | null, @@ -414,18 +395,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,7 +415,7 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { }); }); -Rails.delegate(document, '.rules-list button', 'click', ({ target }) => { +on('click', '.rules-list button', ({ target }) => { if (!(target instanceof HTMLElement)) { return; } diff --git a/app/javascript/entrypoints/theme-selection.ts b/app/javascript/entrypoints/theme-selection.ts new file mode 100644 index 00000000000000..76e46e15f193b0 --- /dev/null +++ b/app/javascript/entrypoints/theme-selection.ts @@ -0,0 +1 @@ +import '../inline/theme-selection'; diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx new file mode 100644 index 00000000000000..9fff41a1330438 --- /dev/null +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -0,0 +1,67 @@ +import { createRoot } from 'react-dom/client'; + +import { Provider as ReduxProvider } from 'react-redux'; + +import { importFetchedStatuses } from '@/mastodon/actions/importer'; +import { hydrateStore } from '@/mastodon/actions/store'; +import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report'; +import { Router } from '@/mastodon/components/router'; +import { WrapstodonSharedPage } from '@/mastodon/features/annual_report/shared_page'; +import { IntlProvider, loadLocale } from '@/mastodon/locales'; +import { loadPolyfills } from '@/mastodon/polyfills'; +import ready from '@/mastodon/ready'; +import { setReport } from '@/mastodon/reducers/slices/annual_report'; +import { store } from '@/mastodon/store'; + +function loaded() { + const mountNode = document.getElementById('wrapstodon'); + if (!mountNode) { + throw new Error('Mount node not found'); + } + const propsNode = document.getElementById('wrapstodon-data'); + if (!propsNode) { + throw new Error('Initial state prop not found'); + } + + const initialState = JSON.parse( + propsNode.textContent, + ) as ApiAnnualReportResponse & { me?: string; domain: string }; + + const report = initialState.annual_reports[0]; + if (!report) { + throw new Error('Initial state report not found'); + } + + // Set up store + store.dispatch( + hydrateStore({ + meta: { + locale: document.documentElement.lang, + me: initialState.me, + domain: initialState.domain, + }, + accounts: initialState.accounts, + }), + ); + store.dispatch(importFetchedStatuses(initialState.statuses)); + + store.dispatch(setReport(report)); + + const root = createRoot(mountNode); + root.render( + + + + + + + , + ); +} + +loadPolyfills() + .then(loadLocale) + .then(() => ready(loaded)) + .catch((err: unknown) => { + console.error(err); + }); diff --git a/app/javascript/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/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..742f6467c7ebfc 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -5,6 +5,7 @@ import { throttle } from 'lodash'; import api from 'flavours/glitch/api'; import { browserHistory } from 'flavours/glitch/components/router'; +import { countableText } from 'flavours/glitch/features/compose/util/counter'; import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'flavours/glitch/settings'; import { recoverHashtags } from 'flavours/glitch/utils/hashtag'; @@ -57,7 +58,6 @@ 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'; @@ -93,6 +93,7 @@ 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) => { @@ -197,24 +198,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 (!(hasText || media.size !== 0 || (hasQuote && spoiler_text?.length))) { + dispatch(showAlert({ + message: messages.blankPostError, + })); + dispatch(focusCompose()); - if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { - status = status + ' 👁️'; + return; } dispatch(submitComposeRequest()); @@ -245,12 +256,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']), @@ -652,6 +664,7 @@ export function fetchComposeSuggestions(token) { fetchComposeSuggestionsEmojis(dispatch, getState, token); break; case '#': + case '#': fetchComposeSuggestionsTags(dispatch, getState, token); break; default: @@ -693,11 +706,20 @@ export function selectComposeSuggestion(position, token, suggestion, path) { 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 +830,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..257c867034d7f3 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,6 +42,10 @@ 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 & { @@ -61,6 +72,39 @@ const simulateModifiedApiResponse = ( 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 ( @@ -122,7 +166,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,6 +213,61 @@ 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( 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..ef834ab50fe54d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/emoji.ts @@ -0,0 +1,22 @@ +import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji'; +import { loadCustomEmoji } from '@/flavours/glitch/features/emoji'; + +export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { + if (emojis.length === 0) { + return; + } + + // First, check if we already have them all. + const { searchCustomEmojisByShortcodes, clearEtag } = + await import('@/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 < emojis.length) { + await clearEtag('custom'); + await loadCustomEmoji(); + } +} diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index f1aabe27474aa1..a25df5705e17a4 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -1,6 +1,7 @@ import { createPollFromServerJSON } from 'flavours/glitch/models/poll'; import { importAccounts } from './accounts'; +import { importCustomEmoji } from './emoji'; import { normalizeStatus } from './normalizer'; import { importPolls } from './polls'; @@ -39,6 +40,10 @@ export function importFetchedAccounts(accounts) { if (account.moved) { processAccount(account.moved); } + + if (account.emojis && account.username === account.acct) { + importCustomEmoji(account.emojis); + } } accounts.forEach(processAccount); @@ -46,11 +51,11 @@ 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 = []; @@ -58,7 +63,7 @@ export function importFetchedStatuses(statuses) { const filters = []; 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) { @@ -80,6 +85,10 @@ export function importFetchedStatuses(statuses) { if (status.card) { status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account)); } + + if (status.emojis && status.account.username === status.account.acct) { + importCustomEmoji(status.emojis); + } } statuses.forEach(processStatus); diff --git a/app/javascript/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/search.ts b/app/javascript/flavours/glitch/actions/search.ts index d0c3e01c7701b2..ba11d8e79e88cf 100644 --- a/app/javascript/flavours/glitch/actions/search.ts +++ b/app/javascript/flavours/glitch/actions/search.ts @@ -147,7 +147,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 index 32ee093afa8423..c291eb772a017c 100644 --- a/app/javascript/flavours/glitch/actions/server.js +++ b/app/javascript/flavours/glitch/actions/server.js @@ -27,7 +27,15 @@ export const fetchServer = () => (dispatch, getState) => { api() .get('/api/v2/instance').then(({ data }) => { - if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); + // Only import the account if it doesn't already exist, + // because the API is cached even for logged in users. + const account = data.contact.account; + if (account) { + const existingAccount = getState().getIn(['accounts', account.id]); + if (!existingAccount) { + dispatch(importFetchedAccount(account)); + } + } dispatch(fetchServerSuccess(data)); }).catch(err => dispatch(fetchServerFail(err))); }; diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index bfb20dc4014a88..6eb5d904bc5f51 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)); }); }; } @@ -204,8 +206,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..27643e991d032b 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,21 @@ 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_NON_STATUS_MARKERS = [ + TIMELINE_GAP, + TIMELINE_SUGGESTIONS, +]; + 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 +61,7 @@ export function updateTimeline(timeline, status, accept) { filtered = filters.length > 0; } - dispatch(importFetchedStatus(status)); + dispatch(importFetchedStatus(status, { bogusQuotePolicy })); dispatch({ type: TIMELINE_UPDATE, 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..e7f4198cde497b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/timelines.test.ts @@ -0,0 +1,60 @@ +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', + }); + }); +}); diff --git a/app/javascript/flavours/glitch/actions/timelines_typed.ts b/app/javascript/flavours/glitch/actions/timelines_typed.ts index 485b94ed524fd3..8d4c7a0178f55b 100644 --- a/app/javascript/flavours/glitch/actions/timelines_typed.ts +++ b/app/javascript/flavours/glitch/actions/timelines_typed.ts @@ -2,6 +2,158 @@ import { createAction } from '@reduxjs/toolkit'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; +import { createAppThunk } from '../store/typed_functions'; + +import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines'; + +export const expandTimelineByKey = createAppThunk( + (args: { key: string; maxId?: number }, { dispatch }) => { + const params = parseTimelineKey(args.key); + if (!params) { + return; + } + + void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId })); + }, +); + +export const expandTimelineByParams = createAppThunk( + (params: TimelineParams & { maxId?: number }, { dispatch }) => { + let url = ''; + const extra: Record = {}; + + if (params.type === 'account') { + url = `/api/v1/accounts/${params.userId}/statuses`; + + if (!params.replies) { + extra.exclude_replies = true; + } + if (!params.boosts) { + extra.exclude_reblogs = true; + } + if (params.pinned) { + extra.pinned = true; + } + if (params.media) { + extra.only_media = true; + } + if (params.tagged) { + extra.tagged = params.tagged; + } + } else if (params.type === 'public') { + url = '/api/v1/timelines/public'; + } + + if (params.maxId) { + extra.max_id = params.maxId.toString(); + } + + return dispatch(expandTimeline(timelineKey(params), url, extra)); + }, +); + +export interface AccountTimelineParams { + type: 'account'; + userId: string; + tagged?: string; + media?: boolean; + pinned?: boolean; + boosts?: boolean; + replies?: boolean; +} +export type PublicTimelineServer = 'local' | 'remote' | 'all'; +export interface PublicTimelineParams { + type: 'public'; + tagged?: string; + server?: PublicTimelineServer; // Defaults to 'all' + media?: boolean; +} +export interface HomeTimelineParams { + type: 'home'; +} +export type TimelineParams = + | AccountTimelineParams + | PublicTimelineParams + | HomeTimelineParams; + +const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const; + +export function timelineKey(params: TimelineParams): string { + const { type } = params; + const key: string[] = [type]; + + if (type === 'account') { + key.push(params.userId); + + const view = ACCOUNT_FILTERS.reduce( + (prev, curr) => prev + (params[curr] ? '1' : '0'), + '', + ); + + key.push(view); + } else if (type === 'public') { + key.push(params.server ?? 'all'); + if (params.media) { + key.push('media'); + } + } + + if (type !== 'home' && params.tagged) { + key.push(params.tagged); + } + + return key.filter(Boolean).join(':'); +} + +export function parseTimelineKey(key: string): TimelineParams | null { + const segments = key.split(':'); + const type = segments[0]; + + if (type === 'account') { + const userId = segments[1]; + if (!userId) { + return null; + } + + const parsed: TimelineParams = { + type: 'account', + userId, + tagged: segments[3], + }; + + 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 isNonStatusId(value: unknown) { + return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); +} + export const disconnectTimeline = createAction( 'timeline/disconnect', ({ timeline }: { timeline: string }) => ({ diff --git a/app/javascript/flavours/glitch/api.ts b/app/javascript/flavours/glitch/api.ts index ca6dec0974884d..2abcc01523e4f2 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,30 @@ export async function apiRequest( return data; } -export async function apiRequestGet( +export async function apiRequestGet( url: ApiUrl, - params?: RequestParamsOrData, + params?: RequestParamsOrData, ) { return apiRequest('GET', url, { params }); } -export async function apiRequestPost( +export async function apiRequestPost( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('POST', url, { data }); } -export async function apiRequestPut( +export async function apiRequestPut( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('PUT', url, { data }); } -export async function apiRequestDelete( - url: ApiUrl, - params?: RequestParamsOrData, -) { +export async function apiRequestDelete< + ApiResponse = unknown, + ApiParams = unknown, +>(url: ApiUrl, params?: RequestParamsOrData) { return apiRequest('DELETE', url, { params }); } diff --git a/app/javascript/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..49ea60c089c904 --- /dev/null +++ b/app/javascript/flavours/glitch/api/collections.ts @@ -0,0 +1,39 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'flavours/glitch/api'; + +import type { + ApiWrappedCollectionJSON, + ApiCollectionWithAccountsJSON, + ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, + ApiCollectionsJSON, +} from '../api_types/collections'; + +export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => + apiRequestPost('v1_alpha/collections', collection); + +export const apiUpdateCollection = ({ + id, + ...collection +}: ApiUpdateCollectionPayload) => + apiRequestPut( + `v1_alpha/collections/${id}`, + collection, + ); + +export const apiDeleteCollection = (collectionId: string) => + apiRequestDelete(`v1_alpha/collections/${collectionId}`); + +export const apiGetCollection = (collectionId: string) => + apiRequestGet( + `v1_alpha/collections/${collectionId}`, + ); + +export const apiGetAccountCollections = (accountId: string) => + apiRequestGet( + `v1_alpha/accounts/${accountId}/collections`, + ); diff --git a/app/javascript/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..cded45f1a3b160 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/collections.ts @@ -0,0 +1,83 @@ +// See app/serializers/rest/base_collection_serializer.rb + +import type { ApiAccountJSON } from './accounts'; +import type { ApiTagJSON } from './statuses'; + +/** + * Returned when fetching all collections for an account, + * doesn't contain account and item data + */ +export interface ApiCollectionJSON { + account_id: string; + + id: string; + uri: string; + local: boolean; + item_count: number; + + name: string; + description: string; + tag?: ApiTagJSON; + language: string; + sensitive: boolean; + discoverable: boolean; + + created_at: string; + updated_at: string; + + items: CollectionAccountItem[]; +} + +/** + * Returned when fetching all collections for an account + */ +export interface ApiCollectionsJSON { + collections: ApiCollectionJSON[]; +} + +/** + * Returned when creating, updating, and adding to a collection + */ +export interface ApiWrappedCollectionJSON { + collection: ApiCollectionJSON; +} + +/** + * Returned when fetching a single collection + */ +export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON { + accounts: ApiAccountJSON[]; +} + +/** + * Nested account item + */ +interface CollectionAccountItem { + account_id?: string; // Only present when state is 'accepted' (or the collection is your own) + state: 'pending' | 'accepted' | 'rejected' | 'revoked'; + position: number; +} + +export interface WrappedCollectionAccountItem { + collection_item: CollectionAccountItem; +} + +/** + * Payload types + */ + +type CommonPayloadFields = Pick< + ApiCollectionJSON, + 'name' | 'description' | 'sensitive' | 'discoverable' +> & { + tag_name?: string | null; + language?: ApiCollectionJSON['language']; +}; + +export interface ApiUpdateCollectionPayload extends Partial { + id: string; +} + +export interface ApiCreateCollectionPayload extends CommonPayloadFields { + account_ids?: string[]; +} diff --git a/app/javascript/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/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts index c4556fa8e5577a..50b03cc109e3c7 100644 --- a/app/javascript/flavours/glitch/api_types/notifications.ts +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -102,8 +102,7 @@ export interface ApiAccountWarningJSON { appeal: unknown; } -interface ModerationWarningNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface ModerationWarningNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'moderation_warning'; moderation_warning: ApiAccountWarningJSON; } @@ -123,14 +122,12 @@ export interface ApiAccountRelationshipSeveranceEventJSON { created_at: string; } -interface AccountRelationshipSeveranceNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface AccountRelationshipSeveranceNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } -interface AccountRelationshipSeveranceNotificationJSON - extends BaseNotificationJSON { +interface AccountRelationshipSeveranceNotificationJSON extends BaseNotificationJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts index 4ecb34bfe1bb6c..157be8441db4f9 100644 --- a/app/javascript/flavours/glitch/api_types/statuses.ts +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -41,21 +41,20 @@ export interface ApiPreviewCardJSON { url: string; title: string; description: string; - language: string; - type: string; + language: string | null; + type: 'video' | 'link'; author_name: string; author_url: string; - author_account?: ApiAccountJSON; provider_name: string; provider_url: string; html: string; width: number; height: number; - image: string; + image: string | null; image_description: string; embed_url: string; blurhash: string; - published_at: string; + published_at: string | null; authors: ApiPreviewCardAuthorJSON[]; } diff --git a/app/javascript/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/account.tsx b/app/javascript/flavours/glitch/components/account/index.tsx similarity index 97% rename from app/javascript/flavours/glitch/components/account.tsx rename to app/javascript/flavours/glitch/components/account/index.tsx index 826d3c3ebb49b0..bcb84f2f4e8016 100644 --- a/app/javascript/flavours/glitch/components/account.tsx +++ b/app/javascript/flavours/glitch/components/account/index.tsx @@ -4,6 +4,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { blockAccount, @@ -33,7 +34,7 @@ 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'; +import { Permalink } from '../permalink'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -333,9 +334,10 @@ export const Account: React.FC = ({ {account && withBio && (account.note.length > 0 ? ( -

) : (
diff --git a/app/javascript/flavours/glitch/components/account_bio.tsx b/app/javascript/flavours/glitch/components/account_bio.tsx index ea9cb2a6288b2a..6d4ab1ddd49b2c 100644 --- a/app/javascript/flavours/glitch/components/account_bio.tsx +++ b/app/javascript/flavours/glitch/components/account_bio.tsx @@ -1,10 +1,9 @@ -import { useCallback } from 'react'; +import classNames from 'classnames'; -import { useLinks } from 'flavours/glitch/hooks/useLinks'; - -import { EmojiHTML } from '../features/emoji/emoji_html'; import { useAppSelector } from '../store'; -import { isModernEmojiEnabled } from '../utils/environment'; + +import { EmojiHTML } from './emoji/html'; +import { useElementHandledLink } from './status/handled_link'; interface AccountBioProps { className: string; @@ -17,22 +16,16 @@ export const AccountBio: React.FC = ({ accountId, showDropdown = false, }) => { - const handleClick = useLinks(showDropdown); - const handleNodeChange = useCallback( - (node: HTMLDivElement | null) => { - if (!showDropdown || !node || node.childNodes.length === 0) { - return; - } - addDropdownToHashtags(node, accountId); - }, - [showDropdown, accountId], - ); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: showDropdown ? accountId : undefined, + }); + const note = useAppSelector((state) => { const account = state.accounts.get(accountId); if (!account) { return ''; } - return isModernEmojiEnabled() ? account.note : account.note_emojified; + return account.note_emojified; }); const extraEmojis = useAppSelector((state) => { const account = state.accounts.get(accountId); @@ -44,33 +37,11 @@ export const AccountBio: React.FC = ({ } return ( -
- -
+ ); }; - -function addDropdownToHashtags(node: HTMLElement | null, accountId: string) { - if (!node) { - return; - } - for (const childNode of node.childNodes) { - if (!(childNode instanceof HTMLElement)) { - continue; - } - if ( - childNode instanceof HTMLAnchorElement && - (childNode.classList.contains('hashtag') || - childNode.innerText.startsWith('#')) && - !childNode.dataset.menuHashtag - ) { - childNode.dataset.menuHashtag = accountId; - } else if (childNode.childNodes.length > 0) { - addDropdownToHashtags(childNode, accountId); - } - } -} diff --git a/app/javascript/flavours/glitch/components/account_fields.tsx b/app/javascript/flavours/glitch/components/account_fields.tsx index 768eb1fa4b343d..d4c4cf1dbf4379 100644 --- a/app/javascript/flavours/glitch/components/account_fields.tsx +++ b/app/javascript/flavours/glitch/components/account_fields.tsx @@ -1,42 +1,71 @@ +import { useIntl } from 'react-intl'; + import classNames from 'classnames'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; -import { useLinks } from 'flavours/glitch/hooks/useLinks'; import type { Account } from 'flavours/glitch/models/account'; -export const AccountFields: React.FC<{ - fields: Account['fields']; - limit: number; -}> = ({ fields, limit = -1 }) => { - const handleClick = useLinks(); +import { EmojiHTML } from './emoji/html'; +import { useElementHandledLink } from './status/handled_link'; + +export const AccountFields: React.FC> = ({ + fields, + emojis, +}) => { + const intl = useIntl(); + const htmlHandlers = useElementHandledLink(); if (fields.size === 0) { return null; } return ( -
- {fields.take(limit).map((pair, i) => ( -
-
+ {fields.map((pair, i) => ( +
+ -
- {pair.get('verified_at') && ( - - )} - + {pair.verified_at && ( + + + + )}{' '} +
))} -
+ ); }; + +const dateFormatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; diff --git a/app/javascript/flavours/glitch/components/alert/index.tsx b/app/javascript/flavours/glitch/components/alert/index.tsx index eb0abcb51887d7..3389c9d9fe97ff 100644 --- a/app/javascript/flavours/glitch/components/alert/index.tsx +++ b/app/javascript/flavours/glitch/components/alert/index.tsx @@ -49,7 +49,11 @@ export const Alert: React.FC<{ {hasAction && ( - )} diff --git a/app/javascript/flavours/glitch/components/alt_text_badge.tsx b/app/javascript/flavours/glitch/components/alt_text_badge.tsx index 9b3748b2ca63f3..be6f88bf83cd06 100644 --- a/app/javascript/flavours/glitch/components/alt_text_badge.tsx +++ b/app/javascript/flavours/glitch/components/alt_text_badge.tsx @@ -47,7 +47,7 @@ export const AltTextBadge: React.FC<{ description: string }> = ({ rootClose onHide={handleClose} show={open} - target={anchorRef.current} + target={anchorRef} placement='top-end' flip offset={offset} diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.jsx b/app/javascript/flavours/glitch/components/autosuggest_input.jsx index f707a18e1d697f..9e342a353a169e 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_input.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_input.jsx @@ -28,7 +28,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { return [null, null]; } - word = word.trim().toLowerCase(); + word = word.trim(); if (word.length > 0) { return [left + 1, word]; @@ -61,7 +61,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { static defaultProps = { autoFocus: true, - searchTokens: ['@', ':', '#'], + searchTokens: ['@', '@', ':', '#', '#'], }; state = { @@ -159,8 +159,8 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.input.focus(); }; - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + componentDidUpdate (prevProps) { + if (prevProps.suggestions !== this.props.suggestions && this.props.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { this.setState({ suggestionsHidden: false }); } } diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx index de5accc4b287df..fae078da31b379 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx @@ -25,11 +25,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 +50,7 @@ const AutosuggestTextarea = forwardRef(({ onKeyUp, onKeyDown, onPaste, + onDrop, onFocus, autoFocus = true, lang, @@ -150,12 +151,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,6 +211,7 @@ const AutosuggestTextarea = forwardRef(({ onFocus={handleFocus} onBlur={handleBlur} onPaste={handlePaste} + onDrop={handleDrop} dir='auto' aria-autocomplete='list' aria-label={placeholder} @@ -238,6 +243,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/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.stories.tsx b/app/javascript/flavours/glitch/components/badge.stories.tsx new file mode 100644 index 00000000000000..aaddcaa91aca50 --- /dev/null +++ b/app/javascript/flavours/glitch/components/badge.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import CelebrationIcon from '@/material-icons/400-24px/celebration-fill.svg?react'; + +import * as badges from './badge'; + +const meta = { + component: badges.Badge, + title: 'Components/Badge', + args: { + label: 'Example', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Domain: Story = { + args: { + domain: 'example.com', + }, +}; + +export const CustomIcon: Story = { + 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 Blocked: Story = { + render(args) { + return ; + }, +}; diff --git a/app/javascript/flavours/glitch/components/badge.tsx b/app/javascript/flavours/glitch/components/badge.tsx new file mode 100644 index 00000000000000..0ffb7baa8a1990 --- /dev/null +++ b/app/javascript/flavours/glitch/components/badge.tsx @@ -0,0 +1,87 @@ +import type { FC, ReactNode } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import AdminIcon from '@/images/icons/icon_admin.svg?react'; +import BlockIcon from '@/material-icons/400-24px/block.svg?react'; +import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; +import PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; +import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react'; + +interface BadgeProps { + label: ReactNode; + icon?: ReactNode; + className?: string; + domain?: ReactNode; + roleId?: string; +} + +export const Badge: FC = ({ + icon = , + label, + className, + domain, + roleId, +}) => ( +
+ {icon} + {label} + {domain && {domain}} +
+); + +export const AdminBadge: FC> = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const GroupBadge: FC> = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => ( + } + label={ + + } + className={className} + /> +); + +export const MutedBadge: FC> = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const BlockedBadge: FC> = (props) => ( + } + label={ + + } + {...props} + /> +); 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..f9bba1ec141c95 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout/callout.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { Callout } from '.'; + +const meta = { + title: 'Components/Callout', + args: { + children: 'Contents here', + title: 'Title', + onPrimary: action('Primary action clicked'), + primaryLabel: 'Primary', + onSecondary: action('Secondary action clicked'), + secondaryLabel: 'Secondary', + onClose: action('Close clicked'), + }, + component: Callout, + render(args) { + return ( +
+ +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: 'default', + }, +}; + +export const NoIcon: Story = { + args: { + icon: false, + }, +}; + +export const NoActions: Story = { + args: { + onPrimary: undefined, + onSecondary: undefined, + }, +}; + +export const OnlyText: Story = { + args: { + onClose: undefined, + onPrimary: undefined, + onSecondary: undefined, + icon: false, + }, +}; + +// export const Subtle: Story = { +// args: { +// variant: 'subtle', +// }, +// }; + +export const Feature: Story = { + args: { + variant: 'feature', + }, +}; + +export const Inverted: Story = { + args: { + variant: 'inverted', + }, +}; + +export const Success: Story = { + args: { + variant: 'success', + }, +}; + +export const Warning: Story = { + args: { + variant: 'warning', + }, +}; + +export const Error: Story = { + args: { + variant: 'error', + }, +}; diff --git a/app/javascript/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..a9232ec3a7a8f3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout/index.tsx @@ -0,0 +1,154 @@ +import type { FC, ReactNode } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import ErrorIcon from '@/material-icons/400-24px/error.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; + +import type { IconProp } from '../icon'; +import { Icon } from '../icon'; +import { IconButton } from '../icon_button'; + +import classes from './styles.module.css'; + +export interface CalloutProps { + variant?: + | 'default' + // | 'subtle' + | 'feature' + | 'inverted' + | 'success' + | 'warning' + | 'error'; + title?: ReactNode; + children: ReactNode; + className?: string; + /** Set to false to hide the icon. */ + icon?: IconProp | boolean; + onPrimary?: () => void; + primaryLabel?: string; + onSecondary?: () => void; + secondaryLabel?: string; + onClose?: () => void; + id?: string; + extraContent?: ReactNode; +} + +const variantClasses = { + default: classes.variantDefault as string, + // subtle: classes.variantSubtle as string, + feature: classes.variantFeature as string, + inverted: classes.variantInverted as string, + success: classes.variantSuccess as string, + warning: classes.variantWarning as string, + error: classes.variantError as string, +} as const; + +export const Callout: FC = ({ + className, + variant = 'default', + title, + children, + icon, + onPrimary: primaryAction, + primaryLabel, + onSecondary: secondaryAction, + secondaryLabel, + onClose, + extraContent, + id, +}) => { + const intl = useIntl(); + + return ( + + ); +}; + +const CalloutIcon: FC> = ({ + variant = 'default', + icon, +}) => { + if (icon === false) { + return null; + } + + if (!icon || icon === true) { + switch (variant) { + case 'inverted': + case 'success': + icon = CheckIcon; + break; + case 'warning': + icon = WarningIcon; + break; + case 'error': + icon = ErrorIcon; + break; + default: + icon = InfoIcon; + } + } + + return ; +}; diff --git a/app/javascript/flavours/glitch/components/callout/styles.module.css b/app/javascript/flavours/glitch/components/callout/styles.module.css new file mode 100644 index 00000000000000..7f33c96eae8736 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout/styles.module.css @@ -0,0 +1,128 @@ +.wrapper { + display: flex; + align-items: start; + padding: 12px; + gap: 8px; + background-color: var(--color-bg-brand-softer); + color: var(--color-text-primary); + border-radius: 12px; +} + +.icon { + padding: 4px; + border-radius: 9999px; + width: 1rem; + height: 1rem; + margin-top: -2px; +} + +.content { + display: flex; + gap: 8px; + flex-direction: column; + flex-grow: 1; +} + +@media screen and (width >= 630px) { + .content { + flex-direction: row; + } +} + +.body { + flex-grow: 1; + + h3 { + font-weight: 500; + margin-bottom: 5px; + } +} + +.actionWrapper { + display: flex; + gap: 8px; + align-items: start; +} + +.action { + appearance: none; + background: none; + border: none; + color: inherit; + font-weight: 500; + padding: 0; + text-decoration: underline; + transition: color 0.1s ease-in-out; + + &:hover { + color: var(--color-text-brand-soft); + } +} + +@media (prefers-reduced-motion: reduce) { + .action { + transition: none; + } +} + +.close { + color: inherit; + + svg { + width: 20px; + height: 20px; + } +} + +.variantDefault { + .icon { + background-color: var(--color-bg-brand-soft); + } +} + +/* .variantSubtle { + border: 1px solid var(--color-bg-brand-softer); + background-color: var(--color-bg-primary); + + .icon { + background-color: var(--color-bg-brand-softer); + } +} */ + +.variantFeature { + background-color: var(--color-bg-brand-base); + color: var(--color-text-on-brand-base); + + button:hover { + color: color-mix(var(--color-text-on-brand-base), transparent 20%); + } +} + +.variantInverted { + background-color: var(--color-bg-inverted); + color: var(--color-text-on-inverted); +} + +.variantSuccess { + background-color: var(--color-bg-success-softer); + + .icon { + background-color: var(--color-bg-success-soft); + } +} + +.variantWarning { + background-color: var(--color-bg-warning-softer); + + .icon { + background-color: var(--color-bg-warning-soft); + } +} + +.variantError { + background-color: var(--color-bg-error-softer); + + .icon { + background-color: var(--color-bg-error-soft); + } +} diff --git a/app/javascript/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/column_back_button.tsx b/app/javascript/flavours/glitch/components/column_back_button.tsx index c2afa788cb5d5b..89018c1853722d 100644 --- a/app/javascript/flavours/glitch/components/column_back_button.tsx +++ b/app/javascript/flavours/glitch/components/column_back_button.tsx @@ -30,7 +30,7 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({ const handleClick = useHandleClick(onClick); const component = ( - @@ -193,6 +196,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 +207,7 @@ export const ColumnHeader: React.FC = ({ @@ -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), )} )} @@ -340,11 +309,12 @@ interface DropdownProps { */ scrollKey?: string; status?: ImmutableMap; + needsStatusRefresh?: boolean; forceDropdown?: boolean; 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; } @@ -363,6 +333,7 @@ export const Dropdown = ({ placement = 'bottom', offset = [5, 5], status, + needsStatusRefresh, forceDropdown = false, renderItem, renderHeader, @@ -382,6 +353,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 +371,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 +392,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 +418,15 @@ export const Dropdown = ({ dispatch(fetchRelationships([prefetchAccountId])); } + if (needsStatusRefresh && statusId) { + dispatch( + fetchStatus(statusId, { + forceFetch: true, + alsoFetchContext: false, + }), + ); + } + if (isUserTouching() && !forceDropdown) { dispatch( openModal({ @@ -450,10 +441,11 @@ export const Dropdown = ({ dispatch( openDropdownMenu({ id: currentId, - keyboard: type !== 'click', + keyboard: isKeypressRef.current, scrollKey, }), ); + isKeypressRef.current = false; } } }, @@ -468,6 +460,8 @@ export const Dropdown = ({ items, forceDropdown, handleClose, + statusId, + needsStatusRefresh, ], ); @@ -484,6 +478,9 @@ export const Dropdown = ({ const buttonProps = { disabled, onClick: toggleDropdown, + onKeyDown: handleKeyDown, + onKeyUp: unsetIsKeypress, + onBlur: unsetIsKeypress, 'aria-expanded': open, 'aria-controls': menuId, ref: buttonRef, diff --git a/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx b/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx index 2cc9219fb5d4df..368e71f8803a64 100644 --- a/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx +++ b/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx @@ -58,17 +58,7 @@ export const EditedTimestamp: React.FC<{ }, []); const renderItem = useCallback( - ( - item: HistoryItem, - index: number, - { - onClick, - onKeyUp, - }: { - onClick: React.MouseEventHandler; - onKeyUp: React.KeyboardEventHandler; - }, - ) => { + (item: HistoryItem, index: number, onClick: React.MouseEventHandler) => { const formattedDate = ( - @@ -118,7 +108,7 @@ export const EditedTimestamp: React.FC<{ onItemClick={handleItemClick} forceDropdown > - 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..4d208cf21b6d4e --- /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, + hasError: true, + }, +}; + +export const DisabledChecked: Story = { + args: { + disabled: true, + checked: true, + }, +}; + +export const DisabledUnchecked: Story = { + args: { + disabled: true, + checked: false, + }, +}; + +export const Indeterminate: Story = { + args: { + indeterminate: true, + }, +}; + +export const Plain: Story = { + render(props) { + return ; + }, +}; + +export const Small: Story = { + args: { + size: 14, + }, +}; + +export const Large: Story = { + args: { + size: 36, + }, +}; diff --git a/app/javascript/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..2b6933c8473146 --- /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, hasError, required, ...otherProps }, ref) => ( + + {(inputProps) => } + +)); + +CheckboxField.displayName = 'CheckboxField'; + +export const Checkbox = forwardRef( + ({ className, size, indeterminate, ...otherProps }, ref) => { + const inputRef = useRef(null); + + const handleRef = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate || false; + } + }, [indeterminate]); + + return ( + + ); + }, +); + +Checkbox.displayName = 'Checkbox'; diff --git a/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss b/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss new file mode 100644 index 00000000000000..f222762af51c3f --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss @@ -0,0 +1,19 @@ +.fieldset { + display: flex; + flex-direction: column; + gap: 12px; + color: var(--color-text-primary); + font-size: 15px; +} + +.fieldsWrapper { + display: flex; + flex-direction: column; + row-gap: 8px; + + &[data-layout='horizontal'] { + flex-direction: row; + flex-wrap: wrap; + column-gap: 24px; + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx new file mode 100644 index 00000000000000..d52a95130b13ef --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + +import type { ReactNode, FC } from 'react'; +import { createContext, useId } from 'react'; + +import classes from './fieldset.module.scss'; +import formFieldWrapperClasses from './form_field_wrapper.module.scss'; + +interface FieldsetProps { + legend: ReactNode; + hint?: ReactNode; + name?: string; + hasError?: boolean; + layout?: 'vertical' | 'horizontal'; + children: ReactNode; +} + +export const FieldsetNameContext = createContext(undefined); + +/** + * A fieldset suitable for wrapping a group of checkboxes, + * radio buttons, or other grouped form controls. + */ + +export const Fieldset: FC = ({ + legend, + hint, + name, + hasError, + layout, + children, +}) => { + const uniqueId = useId(); + const labelId = `${uniqueId}-label`; + const hintId = `${uniqueId}-hint`; + const fieldsetName = name || `${uniqueId}-fieldset-name`; + const hasHint = !!hint; + + return ( +
+
+
+ {legend} +
+ {hasHint && ( +

+ {hint} +

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