diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3aa0bbf7da4ec3..a7a8e26d91e540 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # For details, see https://github.com/devcontainers/images/tree/main/src/ruby -FROM mcr.microsoft.com/devcontainers/ruby:1-3.3-bookworm +FROM mcr.microsoft.com/devcontainers/ruby:3.4-trixie # Install node version from .nvmrc WORKDIR /app @@ -9,7 +9,7 @@ RUN /bin/bash --login -i -c "nvm install" # Install additional OS packages RUN apt-get update && \ export DEBIAN_FRONTEND=noninteractive && \ - apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev + apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg libvips42 libpam-dev # Disable download prompt for Corepack ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 0c7ead1c15f1bd..3c9e06116cb913 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version-file: '.nvmrc' @@ -23,7 +23,7 @@ runs: shell: bash run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index 3e232f134c9422..0085aa875cb7d8 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -17,7 +17,7 @@ runs: sudo apt-get install -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: ruby-version: ${{ inputs.ruby-version }} bundler-cache: true diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 678a256b748e16..ddca0bc239cd78 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -113,6 +113,7 @@ ], matchUpdateTypes: ['major'], groupName: 'artifact actions (major)', + extends: ['helpers:pinGitHubActionDigests'], }, { // Update @types/* packages every week, with one grouped PR diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index a599dc68cc1c04..bf4fbc547973ad 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -31,10 +31,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: bundler-cache: true diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index 42e0c43a92853b..c88b62b11722c4 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -48,8 +48,7 @@ jobs: - name: Check for missing strings in English YML run: | - bin/i18n-tasks add-missing -l en - git diff --exit-code + bin/i18n-tasks missing -t used -l en - name: Check for wrong string interpolations run: bin/i18n-tasks check-consistent-interpolations diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index fbc0d9da0c2564..19548c28c1355e 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -16,11 +16,11 @@ jobs: changed: ${{ steps.filter.outputs.src }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: filter with: filters: | @@ -42,7 +42,7 @@ jobs: if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true' steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 @@ -53,10 +53,10 @@ jobs: run: yarn build-storybook - name: Run Chromatic - uses: chromaui/action@v13 + uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} zip: true storybookBuildDir: 'storybook-static' - exitZeroOnChanges: false # Fail workflow if changes are found + 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 6be98390338c72..692f2f9dc9dff5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,11 +37,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -54,7 +54,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -67,6 +67,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 8a6ac6df277dcc..297e1c2f3c3a41 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Increase Git http.postBuffer # This is needed due to a bug in Ubuntu's cURL version? @@ -24,7 +24,7 @@ jobs: # Download the translation files from Crowdin - name: crowdin action - uses: crowdin/github-action@v2 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2 with: upload_sources: false upload_translations: false @@ -50,7 +50,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v7.0.8 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 9658d8770ddd7c..94c41c28737ace 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 2d6c57ec8c0329..795cbb93f1aaec 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index bb6420122a0ebf..9ce90430be2bef 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -36,10 +36,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: bundler-cache: true diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index d17547ded72c5c..33108aba20053a 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index abd0667e0c9da0..e00031a4a18cc4 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -38,15 +38,15 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: bundler-cache: true - name: Set-up RuboCop Problem Matcher - uses: r7kamura/rubocop-problem-matchers-action@v1 + uses: r7kamura/rubocop-problem-matchers-action@59f1a0759f50cc2649849fd850b8487594bb5a81 # v1.2.2 - name: Run rubocop run: bin/rubocop diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index f0fc8b0db729e3..cf594c2c6c4da9 100644 --- a/.github/workflows/rebase-needed.yml +++ b/.github/workflows/rebase-needed.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Check for merge conflicts - uses: eps1lon/actions-label-merge-conflict@v3 + uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3 with: dirtyLabel: 'rebase needed :construction:' repoToken: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 839d0a9a64a025..152df214c480c2 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index b3ecf32b433fbd..2d85c34b689399 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -75,7 +75,7 @@ jobs: BUNDLE_RETRY: 3 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index a49671c9bff3e9..519619fe656f4b 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -35,7 +35,7 @@ jobs: SECRET_KEY_BASE_DUMMY: 1 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -46,7 +46,7 @@ jobs: onlyProduction: 'true' - name: Cache assets from compilation - uses: actions/cache@v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | public/assets @@ -68,7 +68,7 @@ jobs: run: | tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: matrix.mode == 'test' with: path: |- @@ -132,9 +132,9 @@ jobs: - '3.3' - '.ruby-version' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -155,7 +155,7 @@ jobs: bin/flatware fan bin/rails db:test:prepare - name: Cache RSpec persistence file - uses: actions/cache@v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | tmp/rspec/examples.txt @@ -171,99 +171,12 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 with: files: coverage/lcov/*.lcov env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-imagemagick: - name: ImageMagick tests - runs-on: ubuntu-latest - - needs: - - build - - services: - postgres: - image: postgres:14-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10ms - --health-timeout 3s - --health-retries 50 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10ms - --health-timeout 3s - --health-retries 50 - ports: - - 6379:6379 - - env: - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} - RAILS_ENV: test - ALLOW_NOPAM: true - PAM_ENABLED: true - PAM_DEFAULT_SERVICE: pam_test - PAM_CONTROLLED_SERVICE: pam_test_controlled - OIDC_ENABLED: true - OIDC_SCOPE: read - SAML_ENABLED: true - CAS_ENABLED: true - BUNDLE_WITH: 'pam_authentication test' - GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} - MASTODON_USE_LIBVIPS: false - - strategy: - fail-fast: false - matrix: - ruby-version: - - '3.2' - - '3.3' - - '.ruby-version' - steps: - - uses: actions/checkout@v5 - - - uses: actions/download-artifact@v6 - with: - path: './' - name: ${{ github.sha }} - - - name: Expand archived asset artifacts - run: | - tar xvzf artifacts.tar.gz - - - name: Set up Ruby environment - uses: ./.github/actions/setup-ruby - with: - ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick libpam-dev - - - name: Load database schema - run: './bin/rails db:create db:schema:load db:seed' - - - run: bin/rspec --tag attachment_processing - - - name: Upload coverage reports to Codecov - if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@v5 - with: - files: coverage/lcov/mastodon.lcov - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-e2e: name: End to End testing runs-on: ubuntu-latest @@ -314,9 +227,9 @@ jobs: - '.ruby-version' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -339,7 +252,7 @@ jobs: - name: Cache Playwright Chromium browser id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.cache/ms-playwright key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -355,14 +268,14 @@ jobs: - run: bin/rspec spec/system --tag streaming --tag js - name: Archive logs - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: e2e-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: e2e-screenshots-${{ matrix.ruby-version }} @@ -452,9 +365,9 @@ jobs: search-image: opensearchproject/opensearch:2 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -474,14 +387,14 @@ jobs: - run: bin/rspec --tag search - name: Archive logs - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: test-search-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: test-search-screenshots diff --git a/.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.tsx b/.storybook/preview.tsx index abbd193c68190e..efdebc3bda3643 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -21,10 +21,9 @@ import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; -// If you want to run the dark theme during development, -// you can change the below to `/application.scss` -import '../app/javascript/styles/mastodon-light.scss'; +import '../app/javascript/styles/application.scss'; import './styles.css'; +import { modes } from './modes'; const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { query: { as: 'json' }, @@ -50,9 +49,19 @@ const preview: Preview = { dynamicTitle: true, }, }, + theme: { + description: 'Theme for the story', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: [{ value: 'light' }, { value: 'dark' }], + dynamicTitle: true, + }, + }, }, initialGlobals: { locale: 'en', + theme: 'light', }, decorators: [ (Story, { parameters, globals, args, argTypes }) => { @@ -135,6 +144,13 @@ const preview: Preview = { ); }, + (Story, { globals }) => { + const theme = (globals.theme as string) || 'light'; + useEffect(() => { + document.body.setAttribute('data-color-scheme', theme); + }, [theme]); + return ; + }, (Story) => ( @@ -181,6 +197,13 @@ const preview: Preview = { msw: { handlers: mockHandlers, }, + + chromatic: { + modes: { + dark: modes.darkTheme, + light: modes.lightTheme, + }, + }, }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e975479e7650..cfbc450d74a19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 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 diff --git a/Dockerfile b/Dockerfile index b9dcbe59fd2637..c06bc84a3395dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/FEDERATION.md b/FEDERATION.md index eb91d9545fe1ea..0ac44afc3cd37c 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -52,8 +52,8 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques ## 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 accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons. -The following table attempts to summary those limits. +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 | | ------------------------------------------------------------- | ---------- | ---------------------------------- | @@ -67,3 +67,4 @@ The following table attempts to summary those limits. | 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 f5da754b1a779a..9a0e1d609408fd 100644 --- a/Gemfile +++ b/Gemfile @@ -28,7 +28,7 @@ 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 @@ -187,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 3ee1f77aa0b218..d734898522251e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -90,14 +90,14 @@ GEM public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotaterb (4.20.0) + annotaterb (4.22.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1201.0) - aws-sdk-core (3.241.3) + aws-partitions (1.1213.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -105,11 +105,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.120.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-kms (1.121.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + 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) @@ -129,9 +129,9 @@ GEM binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.20.1) + bootsnap (1.22.0) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.2) racc browser (6.2.0) builder (3.3.0) @@ -147,7 +147,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-playwright-driver (0.5.7) + capybara-playwright-driver (0.5.8) addressable capybara playwright-ruby-client (>= 1.16.0) @@ -187,16 +187,16 @@ GEM irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) - devise (4.9.4) + devise (5.0.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0) + railties (>= 7.0) responders warden (~> 1.2.3) - devise-two-factor (6.2.0) - activesupport (>= 7.0, < 8.2) - devise (~> 4.0) - railties (>= 7.0, < 8.2) + devise-two-factor (6.4.0) + activesupport (>= 7.2, < 8.2) + devise (>= 4.0, < 6.0) + railties (>= 7.2, < 8.2) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) @@ -210,7 +210,7 @@ GEM railties (>= 5) dotenv (3.2.0) drb (2.2.3) - dry-cli (1.3.0) + dry-cli (1.4.1) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) elasticsearch-transport (= 7.17.11) @@ -234,9 +234,9 @@ GEM excon (1.3.2) logger fabrication (3.0.0) - faker (3.5.3) + faker (3.6.0) i18n (>= 1.8.11, < 2) - faraday (2.14.0) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger @@ -277,7 +277,7 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.33.2) + google-protobuf (4.33.5) bigdecimal rake (>= 13) googleapis-common-protos-types (1.22.0) @@ -305,8 +305,8 @@ GEM highline (3.1.2) reline hiredis (0.6.3) - hiredis-client (0.26.3) - redis-client (= 0.26.3) + hiredis-client (0.26.4) + redis-client (= 0.26.4) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -343,8 +343,9 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jd-paperclip-azure (3.0.0) @@ -352,7 +353,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.18.0) + json (2.18.1) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -390,7 +391,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) @@ -446,7 +447,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0924) + mime-types-data (3.2026.0203) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (6.0.1) @@ -472,7 +473,7 @@ GEM nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.13) + oj (3.16.15) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.4) @@ -528,7 +529,7 @@ GEM opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-action_pack (0.15.1) opentelemetry-instrumentation-rack (~> 0.29) - opentelemetry-instrumentation-action_view (0.11.1) + opentelemetry-instrumentation-action_view (0.11.2) opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-active_job (0.10.1) opentelemetry-instrumentation-base (~> 0.25) @@ -613,7 +614,7 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) prometheus_exporter (2.3.1) webrick propshaft (1.3.1) @@ -624,7 +625,7 @@ GEM date stringio public_suffix (7.0.2) - puma (7.1.0) + puma (7.2.0) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) @@ -700,7 +701,7 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (7.0.3) + rdoc (7.1.0) erb psych (>= 4.0.0) tsort @@ -708,7 +709,7 @@ GEM reline redcarpet (3.6.1) redis (4.8.1) - redis-client (0.26.3) + redis-client (0.26.4) connection_pool regexp_parser (2.11.3) reline (0.6.3) @@ -753,8 +754,8 @@ GEM rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) - rspec-support (3.13.6) - rubocop (1.82.1) + rspec-support (3.13.7) + rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -762,7 +763,7 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.48.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.49.0) @@ -829,8 +830,9 @@ GEM 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) + ostruct simple_form (5.4.1) actionpack (>= 7.0) activemodel (>= 7.0) @@ -841,7 +843,7 @@ GEM simplecov-html (0.13.2) simplecov-lcov (0.9.0) simplecov_json_formatter (0.1.4) - stackprof (0.2.27) + stackprof (0.2.28) starry (0.2.0) base64 stoplight (5.7.0) @@ -861,7 +863,7 @@ GEM unicode-display_width (>= 1.1.1, < 4) terrapin (1.1.1) climate_control - test-prof (1.5.0) + test-prof (1.5.2) thor (1.5.0) tilt (2.7.0) timeout (0.6.0) @@ -949,7 +951,7 @@ DEPENDENCIES binding_of_caller (~> 1.0) blurhash (~> 0.1) bootsnap - brakeman (~> 7.0) + brakeman (~> 8.0) browser bundler-audit (~> 0.9) capybara (~> 3.39) @@ -964,7 +966,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) @@ -1098,4 +1100,4 @@ RUBY VERSION ruby 3.4.8 BUNDLED WITH - 4.0.3 + 4.0.6 diff --git a/Vagrantfile b/Vagrantfile index 0a34367024070a..a2c0b13b146031 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -29,7 +29,6 @@ sudo apt-get install \ libpq-dev \ libxml2-dev \ libxslt1-dev \ - imagemagick \ nodejs \ redis-server \ redis-tools \ diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index a03f424e0f1da1..5f3c1311a85994 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -6,17 +6,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController 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' @@ -59,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/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index 2f9af8a6fc77f9..238d75bf798842 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -62,7 +62,7 @@ def set_role def resource_params params - .expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []]) + .expect(user_role: [:name, :color, :highlighted, :position, :require_2fa, permissions_as_keys: []]) end end end diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb index 6d115631a2b2d8..b9b58b23d4434d 100644 --- a/app/controllers/api/v1/accounts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/notes_controller.rb @@ -9,9 +9,9 @@ class Api::V1::Accounts::NotesController < Api::BaseController def create if params[:comment].blank? - AccountNote.find_by(account: current_account, target_account: @account)&.destroy + current_account.account_notes.find_by(target_account: @account)&.destroy else - @note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account) + @note = current_account.account_notes.find_or_initialize_by(target_account: @account) @note.comment = params[:comment] @note.save! if @note.changed? end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 72f358bb5bcd95..8e341aa48e61fd 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -23,6 +23,10 @@ def reported_account end def report_params - params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], rule_ids: []) + if Mastodon::Feature.collections_enabled? + params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], collection_ids: [], rule_ids: []) + else + params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], rule_ids: []) + end 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_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb index 21699a5b6f2676..5c78de14e9592b 100644 --- a/app/controllers/api/v1_alpha/collection_items_controller.rb +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -21,13 +21,13 @@ def create @item = AddAccountToCollectionService.new.call(@collection, @account) - render json: @item, serializer: REST::CollectionItemSerializer + render json: @item, serializer: REST::CollectionItemSerializer, adapter: :json end def destroy authorize @collection, :update? - @collection_item.destroy + DeleteCollectionItemService.new.call(@collection_item) head 200 end diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index 9d6b2f9a381072..feea6c6b32e639 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -26,16 +26,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController def index cache_if_unauthenticated! - authorize Collection, :index? + authorize @account, :index_collections? - render json: @collections, each_serializer: REST::BaseCollectionSerializer + 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::CollectionSerializer + render json: @collection, serializer: REST::CollectionWithAccountsSerializer end def create @@ -43,21 +45,21 @@ def create @collection = CreateCollectionService.new.call(collection_creation_params, current_user.account) - render json: @collection, serializer: REST::CollectionSerializer + 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 + UpdateCollectionService.new.call(@collection, collection_update_params) - render json: @collection, serializer: REST::CollectionSerializer + render json: @collection, serializer: REST::CollectionSerializer, adapter: :json end def destroy authorize @collection, :destroy? - @collection.destroy + DeleteCollectionService.new.call(@collection) head 200 end @@ -74,6 +76,7 @@ def set_collections .order(created_at: :desc) .offset(offset_param) .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections = @collections.discoverable unless @account == current_account end def set_collection diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cfcba1fc3cf419..a19fcc7a0aeb1b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -17,9 +17,6 @@ class ApplicationController < ActionController::Base helper_method :current_account helper_method :current_session - helper_method :current_theme - helper_method :color_scheme - helper_method :contrast helper_method :single_user_mode? helper_method :use_seamless_external_login? helper_method :sso_account_settings @@ -64,19 +61,25 @@ def store_referrer return if request.referer.blank? redirect_uri = URI(request.referer) - return if redirect_uri.path.start_with?('/auth') + return if redirect_uri.path.start_with?('/auth', '/settings/two_factor_authentication', '/settings/otp_authentication') stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port store_location_for(:user, stored_url) end + def mfa_setup_path(path_params = {}) + settings_two_factor_authentication_methods_path(path_params) + end + def require_functional! return if current_user.functional? respond_to do |format| format.any do - if current_user.confirmed? + if current_user.missing_2fa? + redirect_to mfa_setup_path + elsif current_user.confirmed? redirect_to edit_user_registration_path else redirect_to auth_setup_path @@ -88,6 +91,8 @@ def require_functional! render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 elsif !current_user.approved? render json: { error: 'Your login is currently pending approval' }, status: 403 + elsif current_user.missing_2fa? + render json: { error: 'Your account requires two-factor authentication' }, status: 403 elsif !current_user.functional? render json: { error: 'Your login is currently disabled' }, status: 403 end @@ -173,35 +178,6 @@ def current_session @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? end - def current_theme - unless Themes.instance.names.include? current_user&.setting_theme - return 'default' unless Themes.instance.names.include? Setting.theme - - return Setting.theme - end - - current_user.setting_theme - end - - def color_scheme - current = current_user&.setting_color_scheme - return current if current && current != 'auto' - - return 'dark' if current_theme.include?('default') || current_theme.include?('contrast') - return 'light' if current_theme.include?('light') - - 'auto' - end - - def contrast - current = current_user&.setting_contrast - return current if current && current != 'auto' - - return 'high' if current_theme.include?('contrast') - - 'auto' - end - def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index b8c21f3ccd7b51..b315b273d58b09 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -130,14 +130,19 @@ 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) } 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 if params[:action] == 'create' false # Disable flash messages for sign-up diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 351f8b574b0ace..71ef01e5ad67b2 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -217,14 +217,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/collection_items_controller.rb b/app/controllers/collection_items_controller.rb new file mode 100644 index 00000000000000..09c1e0e192a63d --- /dev/null +++ b/app/controllers/collection_items_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class CollectionItemsController < ApplicationController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, if: -> { authorized_fetch_mode? } + before_action :set_collection_item + + skip_around_action :set_locale + skip_before_action :require_functional!, unless: :limited_federation_mode? + + def show + respond_to do |format| + format.json do + expires_in(3.minutes, public: public_fetch_mode?) + + render json: @collection_item, + serializer: ActivityPub::FeaturedItemSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_collection_item + @collection_item = @account.curated_collection_items.find(params[:id]) + authorize @collection_item.collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb index c1349915f84dab..9c16d573c57b51 100644 --- a/app/controllers/concerns/accountable_concern.rb +++ b/app/controllers/concerns/accountable_concern.rb @@ -4,10 +4,8 @@ module AccountableConcern extend ActiveSupport::Concern def log_action(action, target) - Admin::ActionLog.create( - account: current_account, - action: action, - target: target - ) + current_account + .action_logs + .create(action:, target:) end end diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index 7fbc469bdf1386..bd97037da600fa 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -42,7 +42,7 @@ def require_challenge! end def render_challenge - render 'auth/challenges/new', layout: 'auth' + render 'auth/challenges/new', layout: params[:oauth] ? 'modal' : 'auth' end def challenge_passed? diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index d55b90ad88761c..267107b6272c1e 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -11,9 +11,7 @@ class MediaProxyController < ApplicationController before_action :authenticate_user!, if: :limited_federation_mode? before_action :set_media_attachment - rescue_from ActiveRecord::RecordInvalid, with: :not_found - rescue_from Mastodon::UnexpectedResponseError, with: :not_found - rescue_from Mastodon::NotPermittedError, with: :not_found + rescue_from ActiveRecord::RecordInvalid, Mastodon::NotPermittedError, Mastodon::UnexpectedResponseError, with: :not_found rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) def show diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bf7edbfdaf3cdb..8b3b41e72fed25 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController - skip_before_action :authenticate_resource_owner! - - before_action :store_current_location - before_action :authenticate_resource_owner! + prepend_before_action :store_current_location layout 'modal' @@ -20,17 +17,15 @@ def store_current_location store_location_for(:user, request.url) end - def render_success - if skip_authorization? || (matching_token? && !truthy_param?('force_login')) - redirect_or_render authorize_response - elsif Doorkeeper.configuration.api_only - render json: pre_auth - else - render :new - end + def can_authorize_response? + !truthy_param?('force_login') && super end def truthy_param?(key) ActiveModel::Type::Boolean.new.cast(params[key]) end + + def mfa_setup_path + super({ oauth: true }) + end end diff --git a/app/controllers/settings/two_factor_authentication/base_controller.rb b/app/controllers/settings/two_factor_authentication/base_controller.rb new file mode 100644 index 00000000000000..8770f927e76e4e --- /dev/null +++ b/app/controllers/settings/two_factor_authentication/base_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Settings + module TwoFactorAuthentication + class BaseController < ::Settings::BaseController + layout -> { truthy_param?(:oauth) ? 'modal' : 'admin' } + end + end +end diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index eae990e79b2229..61e2aef5a8161c 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -4,12 +4,15 @@ module Settings module TwoFactorAuthentication class ConfirmationsController < BaseController include ChallengableConcern + include Devise::Controllers::StoreLocation skip_before_action :require_functional! before_action :require_challenge! before_action :ensure_otp_secret + helper_method :return_to_app_url + def new prepare_two_factor_form end @@ -37,6 +40,10 @@ def create private + def return_to_app_url + stored_location_for(:user) + end + def confirmation_params params.expect(form_two_factor_confirmation: [:otp_attempt]) end diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb index ca8d46afe48199..5460448d995da3 100644 --- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -17,7 +17,7 @@ def show def create session[:new_otp_secret] = User.generate_otp_secret - redirect_to new_settings_two_factor_authentication_confirmation_path + redirect_to new_settings_two_factor_authentication_confirmation_path(params.permit(:oauth)) end private diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb index a6d5c1fe2dd4f5..49579b36779965 100644 --- a/app/controllers/settings/two_factor_authentication_methods_controller.rb +++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb @@ -22,7 +22,7 @@ def disable private def require_otp_enabled - redirect_to settings_otp_authentication_path unless current_user.otp_enabled? + redirect_to settings_otp_authentication_path(params.permit(:oauth)) unless current_user.otp_enabled? end end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 52cec54a7ac671..805a8be450093e 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -29,15 +29,15 @@ def show end format.json do - expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? && !misskey_software? && !@status.expires? - render_with_cache json: @status, content_type: 'application/activity+json', serializer: status_activity_serializer, adapter: ActivityPub::Adapter, cancel_cache: misskey_software? + expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode? && !misskey_software? && !@status.expires? + render_with_cache json: @status, content_type: 'application/activity+json', serializer: status_activity_serializer, for_misskey: misskey_software?, adapter: ActivityPub::Adapter, cancel_cache: misskey_software? end end end def activity expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? && !misskey_software? - render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status, for_misskey: misskey_software?), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, cancel_cache: misskey_software? + render_with_cache json: @status, content_type: 'application/activity+json', serializer: activity_serializer, adapter: ActivityPub::Adapter, for_misskey: misskey_software?, cancel_cache: misskey_software? end def embed @@ -85,14 +85,14 @@ def misskey_software? end def status_activity_serializer - if misskey_software? - ActivityPub::NoteForMisskeySerializer - else - ActivityPub::NoteSerializer - end + ActivityPub::NoteSerializer 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/helpers/admin/content_policies_helper.rb b/app/helpers/admin/content_policies_helper.rb new file mode 100644 index 00000000000000..11c1109ed41f75 --- /dev/null +++ b/app/helpers/admin/content_policies_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Admin::ContentPoliciesHelper + def policy_list(domain_block) + domain_block + .policies + .map { |policy| I18n.t("admin.instances.content_policies.policies.#{policy}") } + .join(' · ') + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5c1b48b7c0e13d..be9f737530b97c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -91,12 +91,6 @@ 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) }), diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index c318f3b36c796b..2bd0e6737ef08a 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -78,6 +78,10 @@ def supported_context?(json) !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end + def supported_security_context?(json) + !json.nil? && equals_or_includes?(json['@context'], 'https://w3id.org/security/v1') + end + def unsupported_uri_scheme?(uri) uri.nil? || !uri.start_with?('http://', 'https://') end @@ -234,6 +238,72 @@ def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_error end end + # Iterate through the pages of an activitypub collection, + # returning the collected items and the number of pages that were fetched. + # + # @param collection_or_uri [String, Hash] + # either the URI or an already-fetched AP object + # @param max_pages [Integer, nil] + # Max pages to fetch, if nil, fetch until no more pages + # @param max_items [Integer, nil] + # Max items to fetch, if nil, fetch until no more items + # @param reference_uri [String, nil] + # If not nil, a URI to compare to the collection URI. + # If the host of the collection URI does not match the reference URI, + # do not fetch the collection page. + # @param on_behalf_of [Account, nil] + # Sign the request on behalf of the Account, if not nil + # @return [Array, Integer>, nil] + # The collection items and the number of pages fetched + def collection_items(collection_or_uri, max_pages: 1, max_items: nil, reference_uri: nil, on_behalf_of: nil) + collection = fetch_collection_page(collection_or_uri, reference_uri: reference_uri, on_behalf_of: on_behalf_of) + return unless collection.is_a?(Hash) + + collection = fetch_collection_page(collection['first'], reference_uri: reference_uri, on_behalf_of: on_behalf_of) if collection['first'].present? + return unless collection.is_a?(Hash) + + items = [] + n_pages = 1 + while collection.is_a?(Hash) + items.concat(as_array(collection_page_items(collection))) + + break if !max_items.nil? && items.size >= max_items + break if !max_pages.nil? && n_pages >= max_pages + + collection = collection['next'].present? ? fetch_collection_page(collection['next'], reference_uri: reference_uri, on_behalf_of: on_behalf_of) : nil + n_pages += 1 + end + + [items, n_pages] + end + + def collection_page_items(collection) + case collection['type'] + when 'Collection', 'CollectionPage' + collection['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + collection['orderedItems'] + end + end + + # Fetch a single collection page + # To get the whole collection, use collection_items + # + # @param collection_or_uri [String, Hash] + # @param reference_uri [String, nil] + # If not nil, a URI to compare to the collection URI. + # If the host of the collection URI does not match the reference URI, + # do not fetch the collection page. + # @param on_behalf_of [Account, nil] + # Sign the request on behalf of the Account, if not nil + # @return [Hash, nil] + def fetch_collection_page(collection_or_uri, reference_uri: nil, on_behalf_of: nil) + return collection_or_uri if collection_or_uri.is_a?(Hash) + return if !reference_uri.nil? && non_matching_uri_hosts?(reference_uri, collection_or_uri) + + fetch_resource_without_id_validation(collection_or_uri, on_behalf_of, raise_on_error: :temporary) + end + def valid_activitypub_content_type?(response) return true if response.mime_type == 'application/activity+json' diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index dbf56f45a04ab6..cbf5638ae4edd4 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -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/theme_helper.rb b/app/helpers/theme_helper.rb index 8e810fd60c4a29..f5f0c6ea95fae1 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -18,9 +18,6 @@ def javascript_inline_tag(path) end def theme_style_tags(theme) - # TODO: get rid of that when we retire the themes and perform the settings migration - theme = 'default' if %w(mastodon-light contrast system).include?(theme) - vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' end @@ -67,6 +64,28 @@ def user_custom_stylesheet ) end + def current_theme + unless Themes.instance.names.include? current_user&.setting_theme + return 'default' unless Themes.instance.names.include? Setting.theme + + return Setting.theme + end + + current_user.setting_theme + end + + def color_scheme + current_user&.setting_color_scheme || 'auto' + end + + def contrast + current_user&.setting_contrast || 'auto' + end + + def page_color_scheme + content_for(:force_color_scheme).presence || color_scheme + end + private def active_custom_stylesheet diff --git a/app/javascript/images/icons/icon_pinned.svg b/app/javascript/images/icons/icon_pinned.svg new file mode 100644 index 00000000000000..90eaa6c933f6a5 --- /dev/null +++ b/app/javascript/images/icons/icon_pinned.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/images/icons/icon_verified.svg b/app/javascript/images/icons/icon_verified.svg new file mode 100644 index 00000000000000..65873b9dc43495 --- /dev/null +++ b/app/javascript/images/icons/icon_verified.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index b4157a502ef795..5960c3dc2a463e 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -153,7 +153,8 @@ export function fetchAccountFail(id, error) { */ export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { - const alreadyFollowing = getState().getIn(['relationships', id, 'following']); + const relationship = getState().getIn(['relationships', id]); + const alreadyFollowing = relationship?.following || relationship?.requested; const locked = getState().getIn(['accounts', id, 'locked'], false); dispatch(followAccountRequest({ id, locked })); diff --git a/app/javascript/mastodon/actions/importer/emoji.ts b/app/javascript/mastodon/actions/importer/emoji.ts index 85043718cf43fb..eafe612f382aa9 100644 --- a/app/javascript/mastodon/actions/importer/emoji.ts +++ b/app/javascript/mastodon/actions/importer/emoji.ts @@ -7,7 +7,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { } // First, check if we already have them all. - const { searchCustomEmojisByShortcodes, clearEtag } = await import( + const { searchCustomEmojisByShortcodes, clearCache } = await import( '@/mastodon/features/emoji/database' ); @@ -17,7 +17,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { // If there's a mismatch, re-import all custom emojis. if (existingEmojis.length < emojis.length) { - await clearEtag('custom'); + await clearCache('custom'); await loadCustomEmoji(); } } diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 1392d3a6679bf2..f3fe61a9359dd4 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -6,6 +6,10 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; +import { + insertPinnedStatusIntoTimelines, + removePinnedStatusFromTimelines, +} from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -595,6 +599,7 @@ export function pin(status) { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); + dispatch(insertPinnedStatusIntoTimelines(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -633,6 +638,7 @@ export function unpin (status) { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); + dispatch(removePinnedStatusFromTimelines(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index 32ee093afa8423..c291eb772a017c 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -27,7 +27,15 @@ export const fetchServer = () => (dispatch, getState) => { api() .get('/api/v2/instance').then(({ data }) => { - if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); + // Only import the account if it doesn't already exist, + // because the API is cached even for logged in users. + const account = data.contact.account; + if (account) { + const existingAccount = getState().getIn(['accounts', account.id]); + if (!existingAccount) { + dispatch(importFetchedAccount(account)); + } + } dispatch(fetchServerSuccess(data)); }).catch(err => dispatch(fetchServerFail(err))); }; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 96304d35d1be48..4aa683f68e7182 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -119,7 +119,7 @@ export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) { }; } -export function redraft(status, raw_text) { +export function redraft(status, raw_text, quoted_status_id = null) { return (dispatch, getState) => { const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']); @@ -127,6 +127,7 @@ export function redraft(status, raw_text) { type: REDRAFT, status, raw_text, + quoted_status_id, maxOptions, }); }; @@ -179,7 +180,7 @@ export function deleteStatus(id, withRedraft = false) { dispatch(importFetchedAccount(response.data.account)); if (withRedraft) { - dispatch(redraft(status, response.data.text)); + dispatch(redraft(status, response.data.text, response.data.quote?.quoted_status?.id)); ensureComposeIsVisible(getState); } else { dispatch(showAlert({ message: messages.deleteSuccess })); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index f48f257d620787..5baa47a0bac828 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -27,10 +27,12 @@ export const TIMELINE_INSERT = 'TIMELINE_INSERT'; // When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions'; export const TIMELINE_GAP = null; +export const TIMELINE_PINNED_VIEW_ALL = 'pinned-view-all'; export const TIMELINE_NON_STATUS_MARKERS = [ TIMELINE_GAP, TIMELINE_SUGGESTIONS, + TIMELINE_PINNED_VIEW_ALL, ]; export const loadPending = timeline => ({ diff --git a/app/javascript/mastodon/actions/timelines.test.ts b/app/javascript/mastodon/actions/timelines.test.ts new file mode 100644 index 00000000000000..239692dd34faa5 --- /dev/null +++ b/app/javascript/mastodon/actions/timelines.test.ts @@ -0,0 +1,85 @@ +import { parseTimelineKey, timelineKey } from './timelines_typed'; + +describe('timelineKey', () => { + test('returns expected key for account timeline with filters', () => { + const key = timelineKey({ + type: 'account', + userId: '123', + replies: true, + boosts: false, + media: true, + }); + expect(key).toBe('account:123:0110'); + }); + + test('returns expected key for account timeline with tag', () => { + const key = timelineKey({ + type: 'account', + userId: '456', + tagged: 'nature', + replies: true, + }); + expect(key).toBe('account:456:0100:nature'); + }); + + test('returns expected key for account timeline with pins', () => { + const key = timelineKey({ + type: 'account', + userId: '789', + pinned: true, + }); + expect(key).toBe('account:789:0001'); + }); +}); + +describe('parseTimelineKey', () => { + test('parses account timeline key with filters correctly', () => { + const params = parseTimelineKey('account:123:1010'); + expect(params).toEqual({ + type: 'account', + userId: '123', + boosts: true, + replies: false, + media: true, + pinned: false, + }); + }); + + test('parses account timeline key with tag correctly', () => { + const params = parseTimelineKey('account:456:0100:nature'); + expect(params).toEqual({ + type: 'account', + userId: '456', + replies: true, + boosts: false, + media: false, + pinned: false, + tagged: 'nature', + }); + }); + + test('parses legacy account timeline key with pinned correctly', () => { + const params = parseTimelineKey('account:789:pinned:nature'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: false, + pinned: true, + tagged: 'nature', + }); + }); + + test('parses legacy account timeline key with media correctly', () => { + const params = parseTimelineKey('account:789:media'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: true, + pinned: false, + }); + }); +}); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index e8468826603dec..98165617790abd 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -1,8 +1,186 @@ import { createAction } from '@reduxjs/toolkit'; +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; -import { TIMELINE_NON_STATUS_MARKERS } from './timelines'; +import type { Status } from '../models/status'; +import { createAppThunk } from '../store/typed_functions'; + +import { + expandTimeline, + insertIntoTimeline, + TIMELINE_NON_STATUS_MARKERS, +} from './timelines'; + +export const expandTimelineByKey = createAppThunk( + (args: { key: string; maxId?: number }, { dispatch }) => { + const params = parseTimelineKey(args.key); + if (!params) { + return; + } + + void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId })); + }, +); + +export const expandTimelineByParams = createAppThunk( + (params: TimelineParams & { maxId?: number }, { dispatch }) => { + let url = ''; + const extra: Record = {}; + + if (params.type === 'account') { + url = `/api/v1/accounts/${params.userId}/statuses`; + + if (!params.replies) { + extra.exclude_replies = true; + } + if (!params.boosts) { + extra.exclude_reblogs = true; + } + if (params.pinned) { + extra.pinned = true; + } + if (params.media) { + extra.only_media = true; + } + if (params.tagged) { + extra.tagged = params.tagged; + } + } else if (params.type === 'public') { + url = '/api/v1/timelines/public'; + } + + if (params.maxId) { + extra.max_id = params.maxId.toString(); + } + + return dispatch(expandTimeline(timelineKey(params), url, extra)); + }, +); + +export interface AccountTimelineParams { + type: 'account'; + userId: string; + tagged?: string; + media?: boolean; + pinned?: boolean; + boosts?: boolean; + replies?: boolean; +} +export type PublicTimelineServer = 'local' | 'remote' | 'all'; +export interface PublicTimelineParams { + type: 'public'; + tagged?: string; + server?: PublicTimelineServer; // Defaults to 'all' + media?: boolean; +} +export interface HomeTimelineParams { + type: 'home'; +} +export type TimelineParams = + | AccountTimelineParams + | PublicTimelineParams + | HomeTimelineParams; + +const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const; + +export function timelineKey(params: TimelineParams): string { + const { type } = params; + const key: string[] = [type]; + + if (type === 'account') { + key.push(params.userId); + + const view = ACCOUNT_FILTERS.reduce( + (prev, curr) => prev + (params[curr] ? '1' : '0'), + '', + ); + + key.push(view); + } else if (type === 'public') { + key.push(params.server ?? 'all'); + if (params.media) { + key.push('media'); + } + } + + if (type !== 'home' && params.tagged) { + key.push(params.tagged); + } + + return key.filter(Boolean).join(':'); +} + +export function parseTimelineKey(key: string): TimelineParams | null { + const segments = key.split(':'); + const type = segments[0]; + + if (type === 'account') { + const userId = segments[1]; + if (!userId) { + return null; + } + + const parsed: TimelineParams = { + type: 'account', + userId, + tagged: segments[3], + pinned: false, + boosts: false, + replies: false, + media: false, + }; + + // Handle legacy keys. + const flagsSegment = segments[2]; + if (!flagsSegment || !/^[01]{4}$/.test(flagsSegment)) { + if (flagsSegment === 'pinned') { + parsed.pinned = true; + } else if (flagsSegment === 'with_replies') { + parsed.replies = true; + } else if (flagsSegment === 'media') { + parsed.media = true; + } + return parsed; + } + + const view = segments[2]?.split('') ?? []; + for (let i = 0; i < view.length; i++) { + const flagName = ACCOUNT_FILTERS[i]; + if (flagName) { + parsed[flagName] = view[i] === '1'; + } + } + return parsed; + } + + if (type === 'public') { + return { + type: 'public', + server: + segments[1] === 'remote' || segments[1] === 'local' + ? segments[1] + : 'all', + tagged: segments[2], + media: segments[3] === 'media', + }; + } + + if (type === 'home') { + return { type: 'home' }; + } + + return null; +} + +export function isTimelineKeyPinned(key: string, accountId?: string) { + const parsedKey = parseTimelineKey(key); + const isPinned = parsedKey?.type === 'account' && parsedKey.pinned; + if (!accountId || !isPinned) { + return isPinned; + } + return parsedKey.userId === accountId; +} export function isNonStatusId(value: unknown) { return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); @@ -24,3 +202,72 @@ export const timelineDelete = createAction<{ references: string[]; reblogOf: string | null; }>('timelines/delete'); + +export const timelineDeleteStatus = createAction<{ + statusId: string; + timelineKey: string; +}>('timelines/deleteStatus'); + +export const insertPinnedStatusIntoTimelines = createAppThunk( + (status: Status, { dispatch, getState }) => { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { + return; + } + + const tags = + ( + status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined + ) + ?.map((tag) => tag.get('name') as string) + .toArray() ?? []; + + const timelines = getState().timelines as ImmutableMap; + const accountTimelines = timelines.filter((_, key) => { + if (!key.startsWith(`account:${currentAccountId}:`)) { + return false; + } + const parsed = parseTimelineKey(key); + const isPinned = parsed?.type === 'account' && parsed.pinned; + if (!isPinned) { + return false; + } + + return !parsed.tagged || tags.includes(parsed.tagged); + }); + + accountTimelines.forEach((_, key) => { + dispatch(insertIntoTimeline(key, status.get('id') as string, 0)); + }); + }, +); + +export const removePinnedStatusFromTimelines = createAppThunk( + (status: Status, { dispatch, getState }) => { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { + return; + } + + const statusId = status.get('id') as string; + const timelines = getState().timelines as ImmutableMap< + string, + ImmutableMap<'items' | 'pendingItems', ImmutableList> + >; + + timelines.forEach((timeline, key) => { + if (!isTimelineKeyPinned(key, currentAccountId)) { + return; + } + + if ( + timeline.get('items')?.includes(statusId) || + timeline.get('pendingItems')?.includes(statusId) + ) { + dispatch(timelineDeleteStatus({ statusId, timelineKey: key })); + } + }); + }, +); diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts new file mode 100644 index 00000000000000..8e35be41c1eac8 --- /dev/null +++ b/app/javascript/mastodon/api/collections.ts @@ -0,0 +1,51 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; + +import type { + ApiWrappedCollectionJSON, + ApiCollectionWithAccountsJSON, + ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, + ApiCollectionsJSON, + WrappedCollectionAccountItem, +} 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`, + ); + +export const apiAddCollectionItem = (collectionId: string, accountId: string) => + apiRequestPost( + `v1_alpha/collections/${collectionId}/items`, + { account_id: accountId }, + ); + +export const apiRemoveCollectionItem = (collectionId: string, itemId: string) => + apiRequestDelete( + `v1_alpha/collections/${collectionId}/items/${itemId}`, + ); diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts new file mode 100644 index 00000000000000..51b87014746ddc --- /dev/null +++ b/app/javascript/mastodon/api_types/collections.ts @@ -0,0 +1,86 @@ +// 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 { + id: string; + account_id?: string; // Only present when state is 'accepted' (or the collection is your own) + state: 'pending' | 'accepted' | 'rejected' | 'revoked'; + position: number; +} + +export interface WrappedCollectionAccountItem { + collection_item: CollectionAccountItem; +} + +/** + * Payload types + */ + +type CommonPayloadFields = Pick< + ApiCollectionJSON, + 'name' | 'description' | 'sensitive' | 'discoverable' +> & { + tag_name?: string | null; + language?: ApiCollectionJSON['language']; +}; + +export interface ApiUpdateCollectionPayload + extends Partial { + id: string; +} + +export interface ApiCreateCollectionPayload extends CommonPayloadFields { + account_ids?: string[]; +} diff --git a/app/javascript/mastodon/api_types/relationships.ts b/app/javascript/mastodon/api_types/relationships.ts index 9f26a0ce9b333d..aa871d6f792fdf 100644 --- a/app/javascript/mastodon/api_types/relationships.ts +++ b/app/javascript/mastodon/api_types/relationships.ts @@ -8,8 +8,9 @@ export interface ApiRelationshipJSON { following: boolean; id: string; languages: string[] | null; - muting_notifications: boolean; muting: boolean; + muting_notifications: boolean; + muting_expires_at: string | null; note: string; notifying: boolean; requested_by: boolean; diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index ce5ea5849b26a0..b67a827b2cda18 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -75,8 +75,9 @@ interface AccountProps { defaultAction?: 'block' | 'mute'; withBio?: boolean; hideButtons?: boolean; - children?: ReactNode; + childrenA?: ReactNode; withMenu?: boolean; + children?: React.ReactNode; } export const Account: React.FC = ({ @@ -87,8 +88,9 @@ export const Account: React.FC = ({ defaultAction, withBio, hideButtons, - children, + childrenA, withMenu = true, + children, }) => { const intl = useIntl(); const { signedIn } = useIdentity(); @@ -358,21 +360,23 @@ export const Account: React.FC = ({ ))} - {!minimal && children && ( + {!minimal && childrenA && (
-
{children}
+
{childrenA}
{dropdown} {button}
)} - {!minimal && !children && ( + {!minimal && !childrenA && (
{dropdown} {button}
)} + + {children} ); diff --git a/app/javascript/mastodon/components/badge.stories.tsx b/app/javascript/mastodon/components/badge.stories.tsx new file mode 100644 index 00000000000000..6c4921809cbbbf --- /dev/null +++ b/app/javascript/mastodon/components/badge.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import CelebrationIcon from '@/material-icons/400-24px/celebration-fill.svg?react'; + +import * as badges from './badge'; + +const meta = { + component: badges.Badge, + title: 'Components/Badge', + args: { + label: undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Example', + }, +}; + +export const Domain: Story = { + args: { + ...Default.args, + domain: 'example.com', + }, +}; + +export const CustomIcon: Story = { + args: { + ...Default.args, + icon: , + }, +}; + +export const Admin: Story = { + args: { + roleId: '1', + }, + render(args) { + return ; + }, +}; + +export const Group: Story = { + render(args) { + return ; + }, +}; + +export const Automated: Story = { + render(args) { + return ; + }, +}; + +export const Muted: Story = { + render(args) { + return ; + }, +}; + +export const MutedWithDate: Story = { + render(args) { + const futureDate = new Date(new Date().getFullYear(), 11, 31).toISOString(); + return ; + }, +}; + +export const Blocked: Story = { + render(args) { + return ; + }, +}; diff --git a/app/javascript/mastodon/components/badge.tsx b/app/javascript/mastodon/components/badge.tsx index b7dc169edbc2aa..07ecdfa46ccd1f 100644 --- a/app/javascript/mastodon/components/badge.tsx +++ b/app/javascript/mastodon/components/badge.tsx @@ -1,20 +1,31 @@ import type { FC, ReactNode } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; +import AdminIcon from '@/images/icons/icon_admin.svg?react'; +import BlockIcon from '@/material-icons/400-24px/block.svg?react'; import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; import PersonIcon from '@/material-icons/400-24px/person.svg?react'; import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; +import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react'; -export const Badge: FC<{ +interface BadgeProps { label: ReactNode; icon?: ReactNode; className?: string; domain?: ReactNode; roleId?: string; -}> = ({ icon = , label, className, domain, roleId }) => ( +} + +export const Badge: FC = ({ + icon = , + label, + className, + domain, + roleId, +}) => (
); -export const GroupBadge: FC<{ className?: string }> = ({ className }) => ( +export const AdminBadge: FC> = ({ label, ...props }) => ( + } + label={ + label ?? ( + + ) + } + {...props} + /> +); + +export const GroupBadge: FC> = ({ label, ...props }) => ( } label={ - + label ?? ( + + ) } - className={className} + {...props} /> ); @@ -44,3 +69,56 @@ export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => ( className={className} /> ); + +export const MutedBadge: FC< + Partial & { expiresAt?: string | null } +> = ({ expiresAt, label, ...props }) => { + // Format the date, only showing the year if it's different from the current year. + const intl = useIntl(); + let formattedDate: string | null = null; + if (expiresAt) { + const expiresDate = new Date(expiresAt); + const isCurrentYear = + expiresDate.getFullYear() === new Date().getFullYear(); + formattedDate = intl.formatDate(expiresDate, { + month: 'short', + day: 'numeric', + ...(isCurrentYear ? {} : { year: 'numeric' }), + }); + } + return ( + } + label={ + label ?? + (formattedDate ? ( + + ) : ( + + )) + } + {...props} + /> + ); +}; + +export const BlockedBadge: FC> = ({ label, ...props }) => ( + } + label={ + label ?? ( + + ) + } + {...props} + /> +); diff --git a/app/javascript/mastodon/components/callout/callout.stories.tsx b/app/javascript/mastodon/components/callout/callout.stories.tsx new file mode 100644 index 00000000000000..f9bba1ec141c95 --- /dev/null +++ b/app/javascript/mastodon/components/callout/callout.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { Callout } from '.'; + +const meta = { + title: 'Components/Callout', + args: { + children: 'Contents here', + title: 'Title', + onPrimary: action('Primary action clicked'), + primaryLabel: 'Primary', + onSecondary: action('Secondary action clicked'), + secondaryLabel: 'Secondary', + onClose: action('Close clicked'), + }, + component: Callout, + render(args) { + return ( +
+ +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: 'default', + }, +}; + +export const NoIcon: Story = { + args: { + icon: false, + }, +}; + +export const NoActions: Story = { + args: { + onPrimary: undefined, + onSecondary: undefined, + }, +}; + +export const OnlyText: Story = { + args: { + onClose: undefined, + onPrimary: undefined, + onSecondary: undefined, + icon: false, + }, +}; + +// export const Subtle: Story = { +// args: { +// variant: 'subtle', +// }, +// }; + +export const Feature: Story = { + args: { + variant: 'feature', + }, +}; + +export const Inverted: Story = { + args: { + variant: 'inverted', + }, +}; + +export const Success: Story = { + args: { + variant: 'success', + }, +}; + +export const Warning: Story = { + args: { + variant: 'warning', + }, +}; + +export const Error: Story = { + args: { + variant: 'error', + }, +}; diff --git a/app/javascript/mastodon/components/callout/dismissible.tsx b/app/javascript/mastodon/components/callout/dismissible.tsx new file mode 100644 index 00000000000000..70a5c850b61317 --- /dev/null +++ b/app/javascript/mastodon/components/callout/dismissible.tsx @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { useDismissible } from '@/mastodon/hooks/useDismissible'; + +import { Callout } from '.'; +import type { CalloutProps } from '.'; + +type DismissibleCalloutProps = CalloutProps & { + id: string; +}; + +export const DismissibleCallout: FC = (props) => { + const { dismiss, wasDismissed } = useDismissible(props.id); + + const { onClose } = props; + const handleClose = useCallback(() => { + dismiss(); + onClose?.(); + }, [dismiss, onClose]); + + if (wasDismissed) { + return null; + } + + return ; +}; diff --git a/app/javascript/mastodon/components/callout/index.tsx b/app/javascript/mastodon/components/callout/index.tsx new file mode 100644 index 00000000000000..a9232ec3a7a8f3 --- /dev/null +++ b/app/javascript/mastodon/components/callout/index.tsx @@ -0,0 +1,154 @@ +import type { FC, ReactNode } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import ErrorIcon from '@/material-icons/400-24px/error.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; + +import type { IconProp } from '../icon'; +import { Icon } from '../icon'; +import { IconButton } from '../icon_button'; + +import classes from './styles.module.css'; + +export interface CalloutProps { + variant?: + | 'default' + // | 'subtle' + | 'feature' + | 'inverted' + | 'success' + | 'warning' + | 'error'; + title?: ReactNode; + children: ReactNode; + className?: string; + /** Set to false to hide the icon. */ + icon?: IconProp | boolean; + onPrimary?: () => void; + primaryLabel?: string; + onSecondary?: () => void; + secondaryLabel?: string; + onClose?: () => void; + id?: string; + extraContent?: ReactNode; +} + +const variantClasses = { + default: classes.variantDefault as string, + // subtle: classes.variantSubtle as string, + feature: classes.variantFeature as string, + inverted: classes.variantInverted as string, + success: classes.variantSuccess as string, + warning: classes.variantWarning as string, + error: classes.variantError as string, +} as const; + +export const Callout: FC = ({ + className, + variant = 'default', + title, + children, + icon, + onPrimary: primaryAction, + primaryLabel, + onSecondary: secondaryAction, + secondaryLabel, + onClose, + extraContent, + id, +}) => { + const intl = useIntl(); + + return ( + + ); +}; + +const CalloutIcon: FC> = ({ + variant = 'default', + icon, +}) => { + if (icon === false) { + return null; + } + + if (!icon || icon === true) { + switch (variant) { + case 'inverted': + case 'success': + icon = CheckIcon; + break; + case 'warning': + icon = WarningIcon; + break; + case 'error': + icon = ErrorIcon; + break; + default: + icon = InfoIcon; + } + } + + return ; +}; diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css new file mode 100644 index 00000000000000..7f33c96eae8736 --- /dev/null +++ b/app/javascript/mastodon/components/callout/styles.module.css @@ -0,0 +1,128 @@ +.wrapper { + display: flex; + align-items: start; + padding: 12px; + gap: 8px; + background-color: var(--color-bg-brand-softer); + color: var(--color-text-primary); + border-radius: 12px; +} + +.icon { + padding: 4px; + border-radius: 9999px; + width: 1rem; + height: 1rem; + margin-top: -2px; +} + +.content { + display: flex; + gap: 8px; + flex-direction: column; + flex-grow: 1; +} + +@media screen and (width >= 630px) { + .content { + flex-direction: row; + } +} + +.body { + flex-grow: 1; + + h3 { + font-weight: 500; + margin-bottom: 5px; + } +} + +.actionWrapper { + display: flex; + gap: 8px; + align-items: start; +} + +.action { + appearance: none; + background: none; + border: none; + color: inherit; + font-weight: 500; + padding: 0; + text-decoration: underline; + transition: color 0.1s ease-in-out; + + &:hover { + color: var(--color-text-brand-soft); + } +} + +@media (prefers-reduced-motion: reduce) { + .action { + transition: none; + } +} + +.close { + color: inherit; + + svg { + width: 20px; + height: 20px; + } +} + +.variantDefault { + .icon { + background-color: var(--color-bg-brand-soft); + } +} + +/* .variantSubtle { + border: 1px solid var(--color-bg-brand-softer); + background-color: var(--color-bg-primary); + + .icon { + background-color: var(--color-bg-brand-softer); + } +} */ + +.variantFeature { + background-color: var(--color-bg-brand-base); + color: var(--color-text-on-brand-base); + + button:hover { + color: color-mix(var(--color-text-on-brand-base), transparent 20%); + } +} + +.variantInverted { + background-color: var(--color-bg-inverted); + color: var(--color-text-on-inverted); +} + +.variantSuccess { + background-color: var(--color-bg-success-softer); + + .icon { + background-color: var(--color-bg-success-soft); + } +} + +.variantWarning { + background-color: var(--color-bg-warning-softer); + + .icon { + background-color: var(--color-bg-warning-soft); + } +} + +.variantError { + background-color: var(--color-bg-error-softer); + + .icon { + background-color: var(--color-bg-error-soft); + } +} diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index d1eab184d77bfd..11f67da80483a7 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -71,10 +71,15 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ return null; } - const { text, description, icon } = item; + const { text, description, icon, iconId } = item; return ( <> - {icon && } + {icon && ( + + )} {text} {Boolean(description) && ( diff --git a/app/javascript/mastodon/components/emoji/context.tsx b/app/javascript/mastodon/components/emoji/context.tsx index 3682b941413105..ceed1d1ff6597b 100644 --- a/app/javascript/mastodon/components/emoji/context.tsx +++ b/app/javascript/mastodon/components/emoji/context.tsx @@ -92,8 +92,11 @@ export const CustomEmojiContext = createContext({}); export const CustomEmojiProvider = ({ children, emojis: rawEmojis, -}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => { - const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]); +}: PropsWithChildren<{ emojis?: CustomEmojiMapArg | null }>) => { + const emojis = useMemo(() => cleanExtraEmojis(rawEmojis), [rawEmojis]); + if (!emojis) { + return children; + } return ( {children} diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index 604d08a7722ddb..bc3eda7a33238a 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -25,7 +25,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( extraEmojis, htmlString, as: asProp = 'div', // Rename for syntax highlighting - className = '', + className, onElement, onAttribute, ...props diff --git a/app/javascript/mastodon/components/empty_state/empty_state.module.scss b/app/javascript/mastodon/components/empty_state/empty_state.module.scss new file mode 100644 index 00000000000000..1707b3bc0818d0 --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/empty_state.module.scss @@ -0,0 +1,23 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + max-width: 600px; + padding: 20px; + gap: 16px; + text-align: center; + color: var(--color-text-primary); +} + +.content { + h3 { + font-size: 17px; + font-weight: 500; + } + + p { + font-size: 15px; + margin-top: 8px; + color: var(--color-text-secondary); + } +} diff --git a/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx new file mode 100644 index 00000000000000..8515a6ea1add89 --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { Button } from '../button'; + +import { EmptyState } from '.'; + +const meta = { + title: 'Components/EmptyState', + component: EmptyState, + argTypes: { + title: { + control: 'text', + type: 'string', + table: { + type: { summary: 'string' }, + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + message: 'Try clearing filters or refreshing the page.', + }, +}; + +export const WithoutMessage: Story = { + args: { + message: undefined, + }, +}; + +export const WithAction: Story = { + args: { + ...Default.args, + // eslint-disable-next-line react/jsx-no-bind + children: , + }, +}; diff --git a/app/javascript/mastodon/components/empty_state/index.tsx b/app/javascript/mastodon/components/empty_state/index.tsx new file mode 100644 index 00000000000000..93f034f3e9373b --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/index.tsx @@ -0,0 +1,32 @@ +import { FormattedMessage } from 'react-intl'; + +import classes from './empty_state.module.scss'; + +/** + * Simple empty state component with a neutral default title and customisable message. + * + * Action buttons can be passed as `children` + */ + +export const EmptyState: React.FC<{ + title?: string | React.ReactElement; + message?: string | React.ReactElement; + children?: React.ReactNode; +}> = ({ + title = ( + + ), + message, + children, +}) => { + return ( +
+
+

{title}

+ {!!message &&

{message}

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

+ {hint} +

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