diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3aa0bbf7da4..ed8484f5b80 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,7 +9,7 @@ RUN /bin/bash --login -i -c "nvm install" # Install additional OS packages RUN apt-get update && \ export DEBIAN_FRONTEND=noninteractive && \ - apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev + apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg libvips42 libpam-dev # Disable download prompt for Corepack ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 diff --git a/.github/workflows/bundlesize-compare.yml b/.github/workflows/bundlesize-compare.yml new file mode 100644 index 00000000000..eb0b4aa7da6 --- /dev/null +++ b/.github/workflows/bundlesize-compare.yml @@ -0,0 +1,75 @@ +name: Compare JS bundle size +on: + pull_request: + paths: + - 'app/javascript/**' + - 'vite.config.mts' + - 'package.json' + - 'yarn.lock' + - .github/workflows/bundlesize-compare.yml + +jobs: + build-head: + name: 'Build head' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@v5 + with: + ref: ${{github.event.pull_request.head.ref}} + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@v5 + with: + name: head-stats + path: ./stats.json + if-no-files-found: error + + build-base: + name: 'Build base' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.base_ref }} + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@v5 + with: + name: base-stats + path: ./stats.json + if-no-files-found: error + + compare: + name: 'Compare base & head bundle sizes' + runs-on: ubuntu-latest + needs: [build-base, build-head] + permissions: + pull-requests: write + steps: + - uses: actions/download-artifact@v5 + + - uses: twk3/rollup-size-compare-action@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + current-stats-json-path: ./head-stats/stats.json + base-stats-json-path: ./base-stats/stats.json diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index fbc0d9da0c2..634c2701865 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -58,5 +58,5 @@ jobs: 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/test-ruby.yml b/.github/workflows/test-ruby.yml index 8f05812d600..316bf831b6f 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -173,93 +173,6 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-imagemagick: - name: ImageMagick tests - runs-on: ubuntu-latest - - needs: - - build - - services: - postgres: - image: postgres:14-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10ms - --health-timeout 3s - --health-retries 50 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10ms - --health-timeout 3s - --health-retries 50 - ports: - - 6379:6379 - - env: - DB_HOST: localhost - DB_USER: postgres - DB_PASS: postgres - COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }} - RAILS_ENV: test - ALLOW_NOPAM: true - PAM_ENABLED: true - PAM_DEFAULT_SERVICE: pam_test - PAM_CONTROLLED_SERVICE: pam_test_controlled - OIDC_ENABLED: true - OIDC_SCOPE: read - SAML_ENABLED: true - CAS_ENABLED: true - BUNDLE_WITH: 'pam_authentication test' - GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} - MASTODON_USE_LIBVIPS: false - - strategy: - fail-fast: false - matrix: - ruby-version: - - '3.2' - - '3.3' - - '.ruby-version' - steps: - - uses: actions/checkout@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 diff --git a/.nvmrc b/.nvmrc index b0195acf781..12fd1fc2777 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.12 +24.13 diff --git a/.storybook/modes.ts b/.storybook/modes.ts new file mode 100644 index 00000000000..89675cb0bfa --- /dev/null +++ b/.storybook/modes.ts @@ -0,0 +1,8 @@ +export const modes = { + darkTheme: { + theme: 'dark', + }, + lightTheme: { + theme: 'light', + }, +} as const; diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index 7a92b6f95ff..7c078c0b3b7 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -1,2 +1,2 @@ - + \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index abbd193c681..d2d34db80d5 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -25,6 +25,7 @@ import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; // you can change the below to `/application.scss` import '../app/javascript/styles/mastodon-light.scss'; import './styles.css'; +import { modes } from './modes'; const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { query: { as: 'json' }, @@ -50,9 +51,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 +146,13 @@ const preview: Preview = { ); }, + (Story, { globals }) => { + const theme = (globals.theme as string) || 'light'; + useEffect(() => { + document.body.setAttribute('data-color-scheme', theme); + }, [theme]); + return ; + }, (Story) => ( @@ -181,6 +199,13 @@ const preview: Preview = { msw: { handlers: mockHandlers, }, + + chromatic: { + modes: { + dark: modes.darkTheme, + light: modes.lightTheme, + }, + }, }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5ec67d854..39e975479e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to this project will be documented in this file. +## [4.5.5] - 2026-01-20 + +### Security + +- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g) +- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp) +- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3) +- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4) + +### Changed + +- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire) + +### Fixed + +- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire) +- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire) +- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire) +- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable) +- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec) +- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec) +- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec) +- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros) +- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable) +- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima) +- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion) + ## [4.5.4] - 2026-01-07 ### Security diff --git a/Dockerfile b/Dockerfile index 865d14402cd..c06bc84a339 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 @@ -183,7 +181,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.17.3 +ARG VIPS_VERSION=8.18.0 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download diff --git a/FEDERATION.md b/FEDERATION.md index 03ea5449de3..0ac44afc3cd 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -48,3 +48,23 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques ### Additional documentation - [Mastodon documentation](https://docs.joinmastodon.org/) + +## Size limits + +Mastodon imposes a few hard limits on federated content. +These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accommodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons. +The following table summarizes those limits. + +| Limited property | Size limit | Consequence of exceeding the limit | +| ------------------------------------------------------------- | ---------- | ---------------------------------- | +| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** | +| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated | +| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated | +| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated | +| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** | +| Account display name (actor `name`) length | 2048 | Display name will be truncated | +| Account note (actor `summary`) length | 20kB | Account note will be truncated | +| Account `attributionDomains` | 256 | List will be truncated | +| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | +| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | +| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated | diff --git a/Gemfile b/Gemfile index 3cc9580fea2..f5da754b1a7 100644 --- a/Gemfile +++ b/Gemfile @@ -55,7 +55,7 @@ gem 'hiredis-client' gem 'htmlentities', '~> 4.3' gem 'http', '~> 5.3.0' gem 'http_accept_language', '~> 2.1' -gem 'httplog', '~> 1.7.0', require: false +gem 'httplog', '~> 1.8.0', require: false gem 'i18n' gem 'idn-ruby', require: 'idn' gem 'inline_svg' @@ -109,12 +109,12 @@ group :opentelemetry do gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false - gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-pg', '~> 0.34.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 2167d017395..ee26939a26c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,8 +96,8 @@ GEM 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.1206.0) + aws-sdk-core (3.241.4) 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.212.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -193,9 +193,9 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (6.2.0) + devise-two-factor (6.3.1) activesupport (>= 7.0, < 8.2) - devise (~> 4.0) + devise (>= 4.0, < 5.0) railties (>= 7.0, < 8.2) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) @@ -234,7 +234,7 @@ 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-net_http (>= 2.0, < 3.5) @@ -282,7 +282,7 @@ GEM rake (>= 13) googleapis-common-protos-types (1.22.0) google-protobuf (~> 4.26) - haml (7.1.0) + haml (7.2.0) temple (>= 0.8.2) thor tilt @@ -291,7 +291,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.68.0) + haml_lint (0.69.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -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) @@ -320,7 +320,8 @@ GEM http_accept_language (2.1.1) httpclient (2.9.0) mutex_m - httplog (1.7.3) + httplog (1.8.0) + benchmark rack (>= 2.0) rainbow (>= 2.0.0) i18n (1.14.8) @@ -520,7 +521,8 @@ GEM opentelemetry-semantic_conventions opentelemetry-helpers-sql (0.3.0) opentelemetry-api (~> 1.7) - opentelemetry-helpers-sql-processor (0.3.1) + opentelemetry-helpers-sql-processor (0.4.0) + opentelemetry-api (~> 1.0) opentelemetry-common (~> 0.21) opentelemetry-instrumentation-action_mailer (0.6.1) opentelemetry-instrumentation-active_support (~> 0.10) @@ -544,17 +546,17 @@ GEM opentelemetry-registry (~> 0.1) opentelemetry-instrumentation-concurrent_ruby (0.24.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-excon (0.26.1) + opentelemetry-instrumentation-excon (0.27.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-faraday (0.30.1) + opentelemetry-instrumentation-faraday (0.31.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http (0.27.1) + opentelemetry-instrumentation-http (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http_client (0.26.1) + opentelemetry-instrumentation-http_client (0.27.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-net_http (0.26.1) + opentelemetry-instrumentation-net_http (0.27.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-pg (0.34.1) + opentelemetry-instrumentation-pg (0.35.0) opentelemetry-helpers-sql opentelemetry-helpers-sql-processor opentelemetry-instrumentation-base (~> 0.25) @@ -587,7 +589,7 @@ GEM ox (2.14.23) bigdecimal (>= 3.0) parallel (1.27.0) - parser (3.3.10.0) + parser (3.3.10.1) ast (~> 2.4.1) racc parslet (2.0.0) @@ -611,7 +613,7 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.7.0) + prism (1.8.0) prometheus_exporter (2.3.1) webrick propshaft (1.3.1) @@ -698,7 +700,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 @@ -706,7 +708,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) @@ -720,10 +722,10 @@ GEM rotp (6.3.0) rouge (4.7.0) rpam2 (4.0.2) - rqrcode (3.1.1) + rqrcode (3.2.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.1) + rqrcode_core (2.1.0) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -752,7 +754,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.6) - rubocop (1.81.7) + rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -760,7 +762,7 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.49.0) @@ -782,7 +784,7 @@ GEM rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rspec (3.8.0) + rubocop-rspec (3.9.0) lint_roller (~> 1.1) rubocop (~> 1.81) rubocop-rspec_rails (2.32.0) @@ -827,8 +829,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) @@ -859,9 +862,9 @@ GEM unicode-display_width (>= 1.1.1, < 4) terrapin (1.1.1) climate_control - test-prof (1.5.0) - thor (1.4.0) - tilt (2.6.1) + test-prof (1.5.1) + thor (1.5.0) + tilt (2.7.0) timeout (0.6.0) tpm-key_attestation (0.14.1) bindata (~> 2.4) @@ -985,7 +988,7 @@ DEPENDENCIES htmlentities (~> 4.3) http (~> 5.3.0) http_accept_language (~> 2.1) - httplog (~> 1.7.0) + httplog (~> 1.8.0) i18n i18n-tasks (~> 1.0) idn-ruby @@ -1021,12 +1024,12 @@ DEPENDENCIES opentelemetry-instrumentation-active_job (~> 0.10.0) opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) - opentelemetry-instrumentation-excon (~> 0.26.0) - opentelemetry-instrumentation-faraday (~> 0.30.0) - opentelemetry-instrumentation-http (~> 0.27.0) - opentelemetry-instrumentation-http_client (~> 0.26.0) - opentelemetry-instrumentation-net_http (~> 0.26.0) - opentelemetry-instrumentation-pg (~> 0.34.0) + opentelemetry-instrumentation-excon (~> 0.27.0) + opentelemetry-instrumentation-faraday (~> 0.31.0) + opentelemetry-instrumentation-http (~> 0.28.0) + opentelemetry-instrumentation-http_client (~> 0.27.0) + opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-pg (~> 0.35.0) opentelemetry-instrumentation-rack (~> 0.29.0) opentelemetry-instrumentation-rails (~> 0.39.0) opentelemetry-instrumentation-redis (~> 0.28.0) diff --git a/SECURITY.md b/SECURITY.md index 12052652e6c..e5790a66fa2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through | 4.5.x | Yes | | 4.4.x | Yes | | 4.3.x | Until 2026-05-06 | -| 4.2.x | Until 2026-01-08 | -| < 4.2 | No | +| < 4.3 | No | diff --git a/Vagrantfile b/Vagrantfile index 0a343670240..a2c0b13b146 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 c80db3500de..a03f424e0f1 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ActivityPub::CollectionsController < ActivityPub::BaseController + SUPPORTED_COLLECTIONS = %w(featured tags).freeze + vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? diff --git a/app/controllers/activitypub/featured_collections_controller.rb b/app/controllers/activitypub/featured_collections_controller.rb new file mode 100644 index 00000000000..872d03423d2 --- /dev/null +++ b/app/controllers/activitypub/featured_collections_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class ActivityPub::FeaturedCollectionsController < ApplicationController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + PER_PAGE = 5 + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, if: -> { authorized_fetch_mode? } + before_action :set_collections + + skip_around_action :set_locale + skip_before_action :require_functional!, unless: :limited_federation_mode? + + def index + respond_to do |format| + format.json do + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) + + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_collections + authorize @account, :index_collections? + @collections = @account.collections.page(params[:page]).per(PER_PAGE) + rescue Mastodon::NotPermittedError + not_found + end + + def page_requested? + params[:page].present? + end + + def next_page_url + ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page) + end + + def prev_page_url + ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page) + end + + def collection_presenter + if page_requested? + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)), + type: :unordered, + size: @account.collections.count, + items: @collections, + part_of: ap_account_featured_collections_url(@account), + next: next_page_url, + prev: prev_page_url + ) + else + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account), + type: :unordered, + size: @account.collections.count, + first: ap_account_featured_collections_url(@account, page: 1) + ) + end + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 1f7abb97fa5..cf46bf21b5e 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -3,6 +3,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController include JsonLdHelper + before_action :skip_large_payload before_action :skip_unknown_actor_activity before_action :require_actor_signature! skip_before_action :authenticate_user! @@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController private + def skip_large_payload + head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE + end + def skip_unknown_actor_activity head 202 if unknown_affected_account? end diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb index 6d115631a2b..b9b58b23d44 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_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb index 21699a5b6f2..3f1f10a3ceb 100644 --- a/app/controllers/api/v1_alpha/collection_items_controller.rb +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -21,7 +21,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController @item = AddAccountToCollectionService.new.call(@collection, @account) - render json: @item, serializer: REST::CollectionItemSerializer + render json: @item, serializer: REST::CollectionItemSerializer, adapter: :json end def destroy diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index d0c4e0f3f04..43520154d52 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,7 +45,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController @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 @@ -51,7 +53,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController @collection.update!(collection_update_params) # TODO: Create a service for this to federate changes - render json: @collection, serializer: REST::CollectionSerializer + render json: @collection, serializer: REST::CollectionSerializer, adapter: :json end def destroy @@ -74,6 +76,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController .order(created_at: :desc) .offset(offset_param) .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections = @collections.discoverable unless @account == current_account end def set_collection @@ -81,11 +84,11 @@ class Api::V1Alpha::CollectionsController < Api::BaseController end def collection_creation_params - params.permit(:name, :description, :sensitive, :discoverable, :tag_name, account_ids: []) + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name, account_ids: []) end def collection_update_params - params.permit(:name, :description, :sensitive, :discoverable, :tag_name) + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name) end def check_feature_enabled diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index ced68d39fc7..2edd92dbc7b 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def set_push_subscription - @push_subscription = ::Web::PushSubscription.find(params[:id]) + @push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id]) end def subscription_params diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index b8c21f3ccd7..b315b273d58 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -130,14 +130,19 @@ class Auth::RegistrationsController < Devise::RegistrationsController 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/collections_controller.rb b/app/controllers/collections_controller.rb new file mode 100644 index 00000000000..3e2ba714702 --- /dev/null +++ b/app/controllers/collections_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class CollectionsController < ApplicationController + include WebAppControllerConcern + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :set_collection + + skip_around_action :set_locale, if: -> { request.format == :json } + skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? + + def show + respond_to do |format| + # TODO: format.html + + format.json do + expires_in expiration_duration, public: true if public_fetch_mode? + render_with_cache json: @collection, content_type: 'application/activity+json', serializer: ActivityPub::FeaturedCollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def set_collection + @collection = @account.collections.find(params[:id]) + authorize @collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end + + def expiration_duration + recently_updated = @collection.updated_at > 15.minutes.ago + recently_updated ? 30.seconds : 5.minutes + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb index c1349915f84..9c16d573c57 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/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index d55b90ad887..267107b6272 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 bf7edbfdaf3..aa8f131d682 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -20,14 +20,8 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController store_location_for(:user, request.url) end - def render_success - if skip_authorization? || (matching_token? && !truthy_param?('force_login')) - redirect_or_render authorize_response - elsif Doorkeeper.configuration.api_only - render json: pre_auth - else - render :new - end + def can_authorize_response? + !truthy_param?('force_login') && super end def truthy_param?(key) diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index e673faca045..65db807d187 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -29,7 +29,7 @@ class StatusesController < ApplicationController end format.json do - expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? + expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode? render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1076d9ced84..b23968e3731 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -89,6 +89,12 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end + def page_color_scheme + return content_for(:force_color_scheme) if content_for(:force_color_scheme) + + color_scheme + end + def label_for_scope(scope) safe_join [ tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), @@ -153,6 +159,19 @@ module ApplicationHelper tag.meta(content: content, property: property) end + def html_attributes + base = { + lang: I18n.locale, + class: html_classes, + 'data-contrast': contrast.parameterize, + 'data-color-scheme': page_color_scheme.parameterize, + } + + base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto' + + base + end + def html_classes output = [] output << content_for(:html_classes) diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index dbf56f45a04..cbf5638ae4e 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 1d642056809..f651a495ffe 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -18,24 +18,23 @@ module ThemeHelper end def theme_style_tags(theme) - if theme == 'system' - ''.html_safe.tap do |tags| - tags << vite_stylesheet_tag('themes/mastodon-light', type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') - tags << vite_stylesheet_tag('themes/default', type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') - end - else - vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' - end + # TODO: get rid of that when we retire the themes and perform the settings migration + theme = 'default' if %w(mastodon-light contrast system).include?(theme) + + vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' end - def theme_color_tags(theme) - if theme == 'system' + def theme_color_tags(color_scheme) + case color_scheme + when 'auto' ''.html_safe.tap do |tags| tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)') tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)') end - else - tag.meta name: 'theme-color', content: theme_color_for(theme) + when 'light' + tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:light] + when 'dark' + tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:dark] end end @@ -65,8 +64,4 @@ module ThemeHelper Setting.custom_css&.then { |content| Digest::SHA256.hexdigest(content) } end end - - def theme_color_for(theme) - theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark] - end end diff --git a/app/javascript/entrypoints/theme-selection.ts b/app/javascript/entrypoints/theme-selection.ts new file mode 100644 index 00000000000..76e46e15f19 --- /dev/null +++ b/app/javascript/entrypoints/theme-selection.ts @@ -0,0 +1 @@ +import '../inline/theme-selection'; diff --git a/app/javascript/images/icons/icon_admin.svg b/app/javascript/images/icons/icon_admin.svg new file mode 100644 index 00000000000..7e40dc46437 --- /dev/null +++ b/app/javascript/images/icons/icon_admin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/javascript/images/icons/icon_verified.svg b/app/javascript/images/icons/icon_verified.svg new file mode 100644 index 00000000000..65873b9dc43 --- /dev/null +++ b/app/javascript/images/icons/icon_verified.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/javascript/inline/theme-selection.js b/app/javascript/inline/theme-selection.js index b3a2b03163e..680fbb23ec2 100644 --- a/app/javascript/inline/theme-selection.js +++ b/app/javascript/inline/theme-selection.js @@ -1,16 +1,17 @@ (function (element) { - const {userTheme} = element.dataset; + const {colorScheme, contrast} = element.dataset; const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)'); const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)'); const updateColorScheme = () => { - const useDarkMode = userTheme === 'system' ? colorSchemeMediaWatcher.matches : userTheme !== 'mastodon-light'; - element.dataset.mode = useDarkMode ? 'dark' : 'light'; + const useDarkMode = colorScheme === 'auto' ? colorSchemeMediaWatcher.matches : colorScheme === 'dark'; + + element.dataset.colorScheme = useDarkMode ? 'dark' : 'light'; }; const updateContrast = () => { - const useHighContrast = userTheme === 'contrast' || contrastMediaWatcher.matches; + const useHighContrast = contrast === 'high' || contrastMediaWatcher.matches; element.dataset.contrast = useHighContrast ? 'high' : 'default'; } diff --git a/app/javascript/mastodon/actions/directory.ts b/app/javascript/mastodon/actions/directory.ts index 34ac309c66c..a50a377ffcf 100644 --- a/app/javascript/mastodon/actions/directory.ts +++ b/app/javascript/mastodon/actions/directory.ts @@ -6,15 +6,17 @@ import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; +const DIRECTORY_FETCH_LIMIT = 20; + export const fetchDirectory = createDataLoadingThunk( 'directory/fetch', async (params: Parameters[0]) => - apiGetDirectory(params), + apiGetDirectory(params, DIRECTORY_FETCH_LIMIT), (data, { dispatch }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchRelationships(data.map((x) => x.id))); - return { accounts: data }; + return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT }; }, ); @@ -26,12 +28,15 @@ export const expandDirectory = createDataLoadingThunk( 'items', ]) as ImmutableList; - return apiGetDirectory({ ...params, offset: loadedItems.size }, 20); + return apiGetDirectory( + { ...params, offset: loadedItems.size }, + DIRECTORY_FETCH_LIMIT, + ); }, (data, { dispatch }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchRelationships(data.map((x) => x.id))); - return { accounts: data }; + return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT }; }, ); diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index 32ee093afa8..c291eb772a0 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/timelines.test.ts b/app/javascript/mastodon/actions/timelines.test.ts new file mode 100644 index 00000000000..e7f4198cde4 --- /dev/null +++ b/app/javascript/mastodon/actions/timelines.test.ts @@ -0,0 +1,60 @@ +import { parseTimelineKey, timelineKey } from './timelines_typed'; + +describe('timelineKey', () => { + test('returns expected key for account timeline with filters', () => { + const key = timelineKey({ + type: 'account', + userId: '123', + replies: true, + boosts: false, + media: true, + }); + expect(key).toBe('account:123:0110'); + }); + + test('returns expected key for account timeline with tag', () => { + const key = timelineKey({ + type: 'account', + userId: '456', + tagged: 'nature', + replies: true, + }); + expect(key).toBe('account:456:0100:nature'); + }); + + test('returns expected key for account timeline with pins', () => { + const key = timelineKey({ + type: 'account', + userId: '789', + pinned: true, + }); + expect(key).toBe('account:789:0001'); + }); +}); + +describe('parseTimelineKey', () => { + test('parses account timeline key with filters correctly', () => { + const params = parseTimelineKey('account:123:1010'); + expect(params).toEqual({ + type: 'account', + userId: '123', + boosts: true, + replies: false, + media: true, + pinned: false, + }); + }); + + test('parses account timeline key with tag correctly', () => { + const params = parseTimelineKey('account:456:0100:nature'); + expect(params).toEqual({ + type: 'account', + userId: '456', + replies: true, + boosts: false, + media: false, + pinned: false, + tagged: 'nature', + }); + }); +}); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index e8468826603..d1fcfb1c658 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -2,7 +2,153 @@ import { createAction } from '@reduxjs/toolkit'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; -import { TIMELINE_NON_STATUS_MARKERS } from './timelines'; +import { createAppThunk } from '../store/typed_functions'; + +import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines'; + +export const expandTimelineByKey = createAppThunk( + (args: { key: string; maxId?: number }, { dispatch }) => { + const params = parseTimelineKey(args.key); + if (!params) { + return; + } + + void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId })); + }, +); + +export const expandTimelineByParams = createAppThunk( + (params: TimelineParams & { maxId?: number }, { dispatch }) => { + let url = ''; + const extra: Record = {}; + + if (params.type === 'account') { + url = `/api/v1/accounts/${params.userId}/statuses`; + + if (!params.replies) { + extra.exclude_replies = true; + } + if (!params.boosts) { + extra.exclude_reblogs = true; + } + if (params.pinned) { + extra.pinned = true; + } + if (params.media) { + extra.only_media = true; + } + if (params.tagged) { + extra.tagged = params.tagged; + } + } else if (params.type === 'public') { + url = '/api/v1/timelines/public'; + } + + if (params.maxId) { + extra.max_id = params.maxId.toString(); + } + + return dispatch(expandTimeline(timelineKey(params), url, extra)); + }, +); + +export interface AccountTimelineParams { + type: 'account'; + userId: string; + tagged?: string; + media?: boolean; + pinned?: boolean; + boosts?: boolean; + replies?: boolean; +} +export type PublicTimelineServer = 'local' | 'remote' | 'all'; +export interface PublicTimelineParams { + type: 'public'; + tagged?: string; + server?: PublicTimelineServer; // Defaults to 'all' + media?: boolean; +} +export interface HomeTimelineParams { + type: 'home'; +} +export type TimelineParams = + | AccountTimelineParams + | PublicTimelineParams + | HomeTimelineParams; + +const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const; + +export function timelineKey(params: TimelineParams): string { + const { type } = params; + const key: string[] = [type]; + + if (type === 'account') { + key.push(params.userId); + + const view = ACCOUNT_FILTERS.reduce( + (prev, curr) => prev + (params[curr] ? '1' : '0'), + '', + ); + + key.push(view); + } else if (type === 'public') { + key.push(params.server ?? 'all'); + if (params.media) { + key.push('media'); + } + } + + if (type !== 'home' && params.tagged) { + key.push(params.tagged); + } + + return key.filter(Boolean).join(':'); +} + +export function parseTimelineKey(key: string): TimelineParams | null { + const segments = key.split(':'); + const type = segments[0]; + + if (type === 'account') { + const userId = segments[1]; + if (!userId) { + return null; + } + + const parsed: TimelineParams = { + type: 'account', + userId, + tagged: segments[3], + }; + + const view = segments[2]?.split('') ?? []; + for (let i = 0; i < view.length; i++) { + const flagName = ACCOUNT_FILTERS[i]; + if (flagName) { + parsed[flagName] = view[i] === '1'; + } + } + return parsed; + } + + if (type === 'public') { + return { + type: 'public', + server: + segments[1] === 'remote' || segments[1] === 'local' + ? segments[1] + : 'all', + tagged: segments[2], + media: segments[3] === 'media', + }; + } + + if (type === 'home') { + return { type: 'home' }; + } + + return null; +} export function isNonStatusId(value: unknown) { return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 1820e00a537..2af29c783e0 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -128,15 +128,18 @@ export default function api(withAuthorization = true) { } type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; -type RequestParamsOrData = Record; +type RequestParamsOrData = T | Record; -export async function apiRequest( +export async function apiRequest< + ApiResponse = unknown, + ApiParamsOrData = unknown, +>( method: Method, url: string, args: { signal?: AbortSignal; - params?: RequestParamsOrData; - data?: RequestParamsOrData; + params?: RequestParamsOrData; + data?: RequestParamsOrData; timeout?: number; } = {}, ) { @@ -149,30 +152,30 @@ export async function apiRequest( return data; } -export async function apiRequestGet( +export async function apiRequestGet( url: ApiUrl, - params?: RequestParamsOrData, + params?: RequestParamsOrData, ) { return apiRequest('GET', url, { params }); } -export async function apiRequestPost( +export async function apiRequestPost( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('POST', url, { data }); } -export async function apiRequestPut( +export async function apiRequestPut( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('PUT', url, { data }); } -export async function apiRequestDelete( - url: ApiUrl, - params?: RequestParamsOrData, -) { +export async function apiRequestDelete< + ApiResponse = unknown, + ApiParams = unknown, +>(url: ApiUrl, params?: RequestParamsOrData) { return apiRequest('DELETE', url, { params }); } diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts new file mode 100644 index 00000000000..8e3ceb73897 --- /dev/null +++ b/app/javascript/mastodon/api/collections.ts @@ -0,0 +1,39 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; + +import type { + ApiWrappedCollectionJSON, + ApiCollectionWithAccountsJSON, + ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, + ApiCollectionsJSON, +} from '../api_types/collections'; + +export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => + apiRequestPost('v1_alpha/collections', collection); + +export const apiUpdateCollection = ({ + id, + ...collection +}: ApiUpdateCollectionPayload) => + apiRequestPut( + `v1_alpha/collections/${id}`, + collection, + ); + +export const apiDeleteCollection = (collectionId: string) => + apiRequestDelete(`v1_alpha/collections/${collectionId}`); + +export const apiGetCollection = (collectionId: string) => + apiRequestGet( + `v1_alpha/collections/${collectionId}`, + ); + +export const apiGetAccountCollections = (accountId: string) => + apiRequestGet( + `v1_alpha/accounts/${accountId}/collections`, + ); diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts new file mode 100644 index 00000000000..c1a17b5dc26 --- /dev/null +++ b/app/javascript/mastodon/api_types/collections.ts @@ -0,0 +1,82 @@ +// See app/serializers/rest/base_collection_serializer.rb + +import type { ApiAccountJSON } from './accounts'; +import type { ApiTagJSON } from './statuses'; + +/** + * Returned when fetching all collections for an account, + * doesn't contain account and item data + */ +export interface ApiCollectionJSON { + account_id: string; + + id: string; + uri: string; + local: boolean; + item_count: number; + + name: string; + description: string; + tag?: ApiTagJSON; + language: string; + sensitive: boolean; + discoverable: boolean; + + created_at: string; + updated_at: string; + + items: CollectionAccountItem[]; +} + +/** + * Returned when fetching all collections for an account + */ +export interface ApiCollectionsJSON { + collections: ApiCollectionJSON[]; +} + +/** + * Returned when creating, updating, and adding to a collection + */ +export interface ApiWrappedCollectionJSON { + collection: ApiCollectionJSON; +} + +/** + * Returned when fetching a single collection + */ +export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON { + accounts: ApiAccountJSON[]; +} + +/** + * Nested account item + */ +interface CollectionAccountItem { + account_id?: string; // Only present when state is 'accepted' (or the collection is your own) + state: 'pending' | 'accepted' | 'rejected' | 'revoked'; + position: number; +} + +export interface WrappedCollectionAccountItem { + collection_item: CollectionAccountItem; +} + +/** + * Payload types + */ + +type CommonPayloadFields = Pick< + ApiCollectionJSON, + 'name' | 'description' | 'sensitive' | 'discoverable' +> & { + tag_name?: string; +}; + +export interface ApiUpdateCollectionPayload extends Partial { + id: string; +} + +export interface ApiCreateCollectionPayload extends CommonPayloadFields { + account_ids?: string[]; +} diff --git a/app/javascript/mastodon/components/badge.jsx b/app/javascript/mastodon/components/badge.jsx deleted file mode 100644 index 2a335d7f506..00000000000 --- a/app/javascript/mastodon/components/badge.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; -import PersonIcon from '@/material-icons/400-24px/person.svg?react'; -import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; - - -export const Badge = ({ icon = , label, domain, roleId }) => ( -
- {icon} - {label} - {domain && {domain}} -
-); - -Badge.propTypes = { - icon: PropTypes.node, - label: PropTypes.node, - domain: PropTypes.node, - roleId: PropTypes.string -}; - -export const GroupBadge = () => ( - } label={} /> -); - -export const AutomatedBadge = () => ( - } label={} /> -); diff --git a/app/javascript/mastodon/components/badge.tsx b/app/javascript/mastodon/components/badge.tsx new file mode 100644 index 00000000000..b7dc169edbc --- /dev/null +++ b/app/javascript/mastodon/components/badge.tsx @@ -0,0 +1,46 @@ +import type { FC, ReactNode } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; +import PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; + +export const Badge: FC<{ + label: ReactNode; + icon?: ReactNode; + className?: string; + domain?: ReactNode; + roleId?: string; +}> = ({ icon = , label, className, domain, roleId }) => ( +
+ {icon} + {label} + {domain && {domain}} +
+); + +export const GroupBadge: FC<{ className?: string }> = ({ className }) => ( + } + label={ + + } + className={className} + /> +); + +export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => ( + } + label={ + + } + className={className} + /> +); 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 00000000000..f9bba1ec141 --- /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 00000000000..70a5c850b61 --- /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 00000000000..a9232ec3a7a --- /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 00000000000..7f33c96eae8 --- /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/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts new file mode 100644 index 00000000000..8100d560495 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -0,0 +1,4 @@ +export { TextInputField } from './text_input_field'; +export { TextAreaField } from './text_area_field'; +export { ToggleField, PlainToggleField } from './toggle_field'; +export { SelectField } from './select_field'; 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 00000000000..30897adda1f --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/select_field.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SelectField } 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.', + }, + render(args) { + // Component styles require a wrapper class at the moment + return ( +
+ + + + + + + + + + + +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = {}; + +export const Required: Story = { + args: { + required: true, + }, +}; + +export const Optional: Story = { + args: { + required: false, + }, +}; + +export const WithError: Story = { + args: { + required: false, + hasError: true, + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/select_field.tsx b/app/javascript/mastodon/components/form_fields/select_field.tsx new file mode 100644 index 00000000000..aa058fc782e --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/select_field.tsx @@ -0,0 +1,38 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import { forwardRef } from 'react'; + +import { FormFieldWrapper } from './wrapper'; +import type { CommonFieldWrapperProps } from './wrapper'; + +interface Props + extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {} + +/** + * A simple form field for single-item selections. + * Provide selectable items via nested `