diff --git a/.browserslistrc b/.browserslistrc index 0135379d6ea26d..483713a03ec76b 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,6 +1,6 @@ defaults -> 0.2% -firefox >= 78 +> 0.2% and not ios < 15.6 +firefox >= 91 ios >= 15.6 not dead not OperaMini all diff --git a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml index 95efe3fd3cfa5d..e4595810745529 100644 --- a/.github/ISSUE_TEMPLATE/3.troubleshooting.yml +++ b/.github/ISSUE_TEMPLATE/3.troubleshooting.yml @@ -61,7 +61,7 @@ body: value: | Please at least include those informations: - Operating system: (eg. Ubuntu 24.04.2) - - Ruby version: (from `ruby --version`, eg. v4.0.3) + - Ruby version: (from `ruby --version`, eg. v4.0.4) - Node.js version: (from `node --version`, eg. v22.16.0) validations: required: false diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 7f29265c1c5fab..0188c2edd97978 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -16,7 +16,7 @@ runs: # The following is needed because we can not use `cache: true` for `setup-node`, as it does not support Corepack yet and mess up with the cache location if ran after Node is installed - name: Enable corepack shell: bash - run: corepack enable + run: npm i -g corepack - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index 317c1256d51685..aaf6dc752affd8 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -14,10 +14,16 @@ runs: shell: bash run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} + sudo apt-get install --no-install-recommends -y \ + libicu-dev \ + libidn11-dev \ + libvips42 \ + libheif-plugin-aomdec \ + libheif-plugin-libde265 \ + ${{ inputs.additional-system-dependencies }} - name: Set up Ruby - uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 + uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 with: ruby-version: ${{ inputs.ruby-version }} bundler-cache: true diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index 9bd5bd634d3546..0e51abfef27e78 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 + uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 with: bundler-cache: true diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 19548c28c1355e..9ead1916dd47bb 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -20,7 +20,7 @@ jobs: with: fetch-depth: 0 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 id: filter with: filters: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 692f2f9dc9dff5..5217a686e1287b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # 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@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 + uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # 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@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 8043001f910378..880e3ff3a5e5ba 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -16,7 +16,6 @@ on: - '**/*.css' - '**/*.scss' - '.github/workflows/lint-css.yml' - - '.github/stylelint-matcher.json' pull_request: paths: @@ -27,7 +26,6 @@ on: - '**/*.css' - '**/*.scss' - '.github/workflows/lint-css.yml' - - '.github/stylelint-matcher.json' jobs: lint: diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index d7a9988ef702f7..93580260f478a8 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -9,7 +9,6 @@ on: - 'releases/*' - 'stable-*' paths: - - '.github/workflows/haml-lint-problem-matcher.json' - '.github/workflows/lint-haml.yml' - '.haml-lint*.yml' - '.rubocop*.yml' @@ -19,7 +18,6 @@ on: pull_request: paths: - - '.github/workflows/haml-lint-problem-matcher.json' - '.github/workflows/lint-haml.yml' - '.haml-lint*.yml' - '.rubocop*.yml' @@ -39,7 +37,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 + uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 with: bundler-cache: true diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index 3f4e2d4a85e51c..c23b356309eff3 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -13,7 +13,6 @@ on: - '.rubocop*.yml' - '.ruby-version' - 'bin/rubocop' - - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -24,7 +23,6 @@ on: - '.rubocop*.yml' - '.ruby-version' - 'bin/rubocop' - - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -41,7 +39,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby - uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1 + uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1 with: bundler-cache: true diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index e13cc7fcbed863..8912d992b709a6 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -167,11 +167,11 @@ jobs: rspec-persistence-main rspec-persistence - - run: bin/flatware rspec -r ./spec/flatware_helper.rb + - run: bin/flatware rspec - name: Upload coverage reports to Codecov if: matrix.ruby-version == '.ruby-version' - uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 with: files: coverage/lcov/*.lcov env: diff --git a/.ruby-version b/.ruby-version index c4e41f94594c72..c5106e6d139660 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.3 +4.0.4 diff --git a/.simplecov b/.simplecov new file mode 100644 index 00000000000000..79b376c9ae6b76 --- /dev/null +++ b/.simplecov @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +SimpleCov.start 'rails' do + # During parallel runs, ensure unique names for post-run merge + command_name "job-#{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER'] + + if ENV['CI'] + require 'simplecov-lcov' + formatter SimpleCov::Formatter::LcovFormatter + formatter.config.report_with_single_file = true + else + formatter SimpleCov::Formatter::HTMLFormatter + end + + enable_coverage :branch + + add_filter 'lib/linter' + + add_group 'Libraries', 'lib' + add_group 'Policies', 'app/policies' + add_group 'Presenters', 'app/presenters' + add_group 'Search', 'app/chewy' + add_group 'Serializers', 'app/serializers' + add_group 'Services', 'app/services' + add_group 'Validators', 'app/validators' +end diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5dbd2ba98d11..15b25a4c38e94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. +## [4.5.10] - 2026-05-20 + +### Security + +- Fix SSRF protection bypass ([GHSA-crr4-7rm4-8gpw](https://github.com/mastodon/mastodon/security/advisories/GHSA-crr4-7rm4-8gpw), [GHSA-xx55-4rrg-8xg6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xx55-4rrg-8xg6)) +- Fix Linked-Data Signature bypass through JSON-LD graph restructuring features ([GHSA-53m7-2wrh-q839](https://github.com/mastodon/mastodon/security/advisories/GHSA-53m7-2wrh-q839), [GHSA-chgx-jx3p-rf73](https://github.com/mastodon/mastodon/security/advisories/GHSA-chgx-jx3p-rf73)) +- Updated dependencies + +### Fixed + +- Fix type of `interactingObject`, `interactionTarget` and add missing `QuoteAuthorization` (#38940 by @ClearlyClaire) + +### Removed + +- Remove unused devise strategies (#38795 by @ClearlyClaire) + ## [4.5.9] - 2026-04-15 ### Security diff --git a/Dockerfile b/Dockerfile index 1f50c0f1a81bcb..feb5b34b803900 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io" # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="4.0.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="4.0.3" +ARG RUBY_VERSION="4.0.4" # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="24" @@ -234,7 +234,7 @@ FROM media-build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # renovate: datasource=github-tags depName=FFmpeg/FFmpeg extractVersion=^n(?\d+\.\d+(\.\d+)?)$ -ARG FFMPEG_VERSION=8.1 +ARG FFMPEG_VERSION=8.1.1 # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"] ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags @@ -340,10 +340,13 @@ COPY --from=node /usr/local/bin /usr/local/bin COPY --from=node /usr/local/lib /usr/local/lib RUN \ - # Configure Corepack - rm /usr/local/bin/yarn*; \ - corepack enable; \ - corepack prepare --activate; + # Mount local Corepack and Yarn caches from Docker buildx caches + --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ + --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ + # Remove pre-installed Yarn binaries (only present on Node <26) + rm -f /usr/local/bin/yarn*; \ + # Install Corepack + npm i -g corepack; # hadolint ignore=DL3008 RUN \ diff --git a/Gemfile b/Gemfile index f5d72aed1b1b43..d4c1008a4fb49f 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,7 @@ gem 'httplog', '~> 1.8.0', require: false gem 'i18n' gem 'idn-ruby', require: 'idn' gem 'inline_svg' +gem 'ipaddr', '~> 1.2' gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' @@ -101,11 +102,11 @@ gem 'rdf-normalize', '~> 0.5' gem 'prometheus_exporter', '~> 2.2', require: false -gem 'opentelemetry-api', '~> 1.9.0' +gem 'opentelemetry-api', '~> 1.10.0' group :opentelemetry do - gem 'opentelemetry-exporter-otlp', '~> 0.33.0', require: false - gem 'opentelemetry-instrumentation-active_job', '~> 0.11.0', require: false + gem 'opentelemetry-exporter-otlp', '~> 0.34.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.12.0', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.25.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.25.0', require: false gem 'opentelemetry-instrumentation-excon', '~> 0.29.0', require: false @@ -115,7 +116,7 @@ group :opentelemetry do gem 'opentelemetry-instrumentation-net_http', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.36.0', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.31.0', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.41.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.42.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-sidekiq', '~> 0.29.0', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false @@ -134,7 +135,7 @@ group :test do # Browser integration testing gem 'capybara', '~> 3.39' gem 'capybara-playwright-driver' - gem 'playwright-ruby-client', '1.59.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package + gem 'playwright-ruby-client', '1.59.1', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package # Used to reset the database between system tests gem 'database_cleaner-active_record' diff --git a/Gemfile.lock b/Gemfile.lock index 57879896e964e8..70872eff96e1ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -99,8 +99,8 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1242.0) - aws-sdk-core (3.246.0) + aws-partitions (1.1249.0) + aws-sdk-core (3.247.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -108,11 +108,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.124.0) - aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-kms (1.125.0) + aws-sdk-core (~> 3, >= 3.247.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.220.0) - aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-s3 (1.222.0) + aws-sdk-core (~> 3, >= 3.247.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -132,7 +132,7 @@ GEM binding_of_caller (2.0.0) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.24.1) + bootsnap (1.24.4) msgpack (~> 1.2) brakeman (8.0.4) racc @@ -190,7 +190,7 @@ GEM irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) - devise (5.0.3) + devise (5.0.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 7.0) @@ -305,8 +305,8 @@ GEM json highline (3.1.2) reline - hiredis-client (0.28.0) - redis-client (= 0.28.0) + hiredis-client (0.29.0) + redis-client (= 0.29.0) hkdf (0.3.0) htmlentities (4.4.2) http (5.3.1) @@ -343,6 +343,7 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.8.2) + ipaddr (1.2.9) irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) @@ -353,7 +354,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.19.4) + json (2.19.5) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -411,15 +412,13 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) - linzer (0.7.8) + linzer (0.7.9) cgi (>= 0.4.2, < 0.6.0) forwardable (~> 1.3, >= 1.3.3) logger (~> 1.7, >= 1.7.0) net-http (>= 0.6, < 0.10) - openssl (>= 3, < 5) rack (>= 2.2, < 4.0) starry (~> 0.2) - stringio (~> 3.1, >= 3.1.2) uri (~> 1.0, >= 1.0.2) llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) @@ -450,7 +449,7 @@ GEM mime-types-data (3.2026.0414) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.5) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) @@ -508,11 +507,11 @@ GEM openssl (4.0.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.9.0) + opentelemetry-api (1.10.0) logger - opentelemetry-common (0.24.0) + opentelemetry-common (0.25.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.33.0) + opentelemetry-exporter-otlp (0.34.0) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) @@ -524,21 +523,21 @@ GEM opentelemetry-helpers-sql-processor (0.5.0) opentelemetry-api (~> 1.0) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.7.0) + opentelemetry-instrumentation-action_mailer (0.8.0) opentelemetry-instrumentation-active_support (~> 0.10) - opentelemetry-instrumentation-action_pack (0.17.0) + opentelemetry-instrumentation-action_pack (0.18.0) opentelemetry-instrumentation-rack (~> 0.29) - opentelemetry-instrumentation-action_view (0.12.0) + opentelemetry-instrumentation-action_view (0.13.0) opentelemetry-instrumentation-active_support (~> 0.10) - opentelemetry-instrumentation-active_job (0.11.0) + opentelemetry-instrumentation-active_job (0.12.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-active_model_serializers (0.25.0) opentelemetry-instrumentation-active_support (>= 0.7.0) - opentelemetry-instrumentation-active_record (0.12.0) + opentelemetry-instrumentation-active_record (0.13.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-active_storage (0.4.0) + opentelemetry-instrumentation-active_storage (0.5.0) opentelemetry-instrumentation-active_support (~> 0.10) - opentelemetry-instrumentation-active_support (0.11.0) + opentelemetry-instrumentation-active_support (0.12.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-base (0.26.0) opentelemetry-api (~> 1.7) @@ -546,7 +545,7 @@ GEM opentelemetry-registry (~> 0.1) opentelemetry-instrumentation-concurrent_ruby (0.25.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-excon (0.29.0) + opentelemetry-instrumentation-excon (0.29.1) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-faraday (0.33.0) opentelemetry-instrumentation-base (~> 0.25) @@ -562,7 +561,7 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-rack (0.31.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rails (0.41.0) + opentelemetry-instrumentation-rails (0.42.0) opentelemetry-instrumentation-action_mailer (~> 0.7) opentelemetry-instrumentation-action_pack (~> 0.17) opentelemetry-instrumentation-action_view (~> 0.12) @@ -575,19 +574,19 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-sidekiq (0.29.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-registry (0.5.0) + opentelemetry-registry (0.6.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.11.0) + opentelemetry-sdk (1.12.0) logger opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.37.0) + opentelemetry-semantic_conventions (1.37.1) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) ostruct (0.6.3) - ox (2.14.25) + ox (2.14.26) bigdecimal (>= 3.0) parallel (1.28.0) parser (3.3.11.1) @@ -599,7 +598,7 @@ GEM pg (1.6.3) pghero (3.8.0) activerecord (>= 7.2) - playwright-ruby-client (1.59.0) + playwright-ruby-client (1.59.1) base64 concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) @@ -710,7 +709,7 @@ GEM redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.28.0) + redis-client (0.29.0) connection_pool regexp_parser (2.12.0) reline (0.6.3) @@ -770,9 +769,9 @@ GEM rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) - rubocop-capybara (2.22.1) + rubocop-capybara (2.23.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) + rubocop (~> 1.81) rubocop-i18n (3.3.0) lint_roller (~> 1.1) rubocop (>= 1.72.1) @@ -803,7 +802,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (3.2.2) + rubyzip (3.3.0) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.5.0) @@ -817,12 +816,12 @@ GEM securerandom (0.4.1) shoulda-matchers (7.0.1) activesupport (>= 7.1) - sidekiq (8.1.3) + sidekiq (8.1.5) connection_pool (>= 3.0.0) json (>= 2.16.0) logger (>= 1.7.0) rack (>= 3.2.0) - redis-client (>= 0.26.0) + redis-client (>= 0.29.0) sidekiq-bulk (0.2.0) sidekiq sidekiq-scheduler (6.0.2) @@ -852,7 +851,7 @@ GEM concurrent-ruby zeitwerk stringio (3.2.0) - strong_migrations (2.7.0) + strong_migrations (2.8.0) activerecord (>= 7.2) swd (2.0.3) activesupport (>= 3) @@ -994,6 +993,7 @@ DEPENDENCIES i18n-tasks (~> 1.0) idn-ruby inline_svg + ipaddr (~> 1.2) irb (~> 1.8) jd-paperclip-azure (~> 3.0) json @@ -1020,9 +1020,9 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 2.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.8.0) - opentelemetry-api (~> 1.9.0) - opentelemetry-exporter-otlp (~> 0.33.0) - opentelemetry-instrumentation-active_job (~> 0.11.0) + opentelemetry-api (~> 1.10.0) + opentelemetry-exporter-otlp (~> 0.34.0) + opentelemetry-instrumentation-active_job (~> 0.12.0) opentelemetry-instrumentation-active_model_serializers (~> 0.25.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.25.0) opentelemetry-instrumentation-excon (~> 0.29.0) @@ -1032,7 +1032,7 @@ DEPENDENCIES opentelemetry-instrumentation-net_http (~> 0.29.0) opentelemetry-instrumentation-pg (~> 0.36.0) opentelemetry-instrumentation-rack (~> 0.31.0) - opentelemetry-instrumentation-rails (~> 0.41.0) + opentelemetry-instrumentation-rails (~> 0.42.0) opentelemetry-instrumentation-redis (~> 0.29.0) opentelemetry-instrumentation-sidekiq (~> 0.29.0) opentelemetry-sdk (~> 1.4) @@ -1040,7 +1040,7 @@ DEPENDENCIES parslet pg (~> 1.5) pghero - playwright-ruby-client (= 1.59.0) + playwright-ruby-client (= 1.59.1) premailer-rails prometheus_exporter (~> 2.2) propshaft @@ -1097,7 +1097,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 4.0.3 + ruby 4.0.4 BUNDLED WITH 4.0.11 diff --git a/Vagrantfile b/Vagrantfile index a2c0b13b146031..e2d703fac204de 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -115,7 +115,7 @@ gem install bundler foreman bundle install # Install node modules -sudo corepack enable +sudo npm i -g corepack corepack prepare yarn install diff --git a/app/controllers/admin/collections_controller.rb b/app/controllers/admin/collections_controller.rb index 4701500f9f8691..405c779e3d43c8 100644 --- a/app/controllers/admin/collections_controller.rb +++ b/app/controllers/admin/collections_controller.rb @@ -4,13 +4,48 @@ module Admin class CollectionsController < BaseController before_action :set_account before_action :set_collection, only: :show + before_action :set_collections, except: :show + + PER_PAGE = 20 + + def index + authorize [:admin, :collection], :index? + @collection_batch_action = Admin::CollectionBatchAction.new + end def show authorize @collection, :show? end + def batch + authorize [:admin, :collection], :index? + + @collection_batch_action = Admin::CollectionBatchAction.new(admin_collection_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button)) + + @collection_batch_action.save! + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.collections.no_collection_selected') + ensure + redirect_to after_create_redirect_path + end + private + def after_create_redirect_path + report_id = @collections_batch_action&.report_id || params[:report_id] + + if report_id.present? + admin_report_path(report_id) + else + admin_account_collections_path(params[:account_id], params[:page]) + end + end + + def admin_collection_batch_action_params + params + .expect(admin_collection_batch_action: [collection_ids: []]) + end + def set_account @account = Account.find(params[:account_id]) end @@ -18,5 +53,17 @@ def set_account def set_collection @collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id]) end + + def set_collections + @collections = @account.collections.includes(accepted_collection_items: :account).page(params[:page]).per(PER_PAGE) + end + + def action_from_button + if params[:report] + 'report' + elsif params[:remove_from_report] + 'remove_from_report' + end + end end end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index 12f221164fa749..d248a04d61bc91 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -5,7 +5,7 @@ class EmailDomainBlocksController < BaseController def index authorize :email_domain_block, :index? - @email_domain_blocks = EmailDomainBlock.parents.includes(:children).order(id: :desc).page(params[:page]) + @email_domain_blocks = filter_by_domain.page(params[:page]) @form = Form::EmailDomainBlockBatch.new end @@ -57,6 +57,12 @@ def create private + def filter_by_domain + scope = EmailDomainBlock.parents.includes(:children).order(id: :desc) + scope.merge!(EmailDomainBlock.matches_domain(params[:domain])) if params[:domain].present? + scope + end + def set_resolved_records @resolved_records = DomainResource.new(@email_domain_block.domain).mx end diff --git a/app/controllers/admin/email_subscriptions/additional_footer_texts_controller.rb b/app/controllers/admin/email_subscriptions/additional_footer_texts_controller.rb new file mode 100644 index 00000000000000..fcc774a25696d2 --- /dev/null +++ b/app/controllers/admin/email_subscriptions/additional_footer_texts_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Admin::EmailSubscriptions::AdditionalFooterTextsController < Admin::SettingsController + private + + def after_update_redirect_path + admin_email_subscriptions_path + end +end diff --git a/app/controllers/admin/email_subscriptions/setups_controller.rb b/app/controllers/admin/email_subscriptions/setups_controller.rb new file mode 100644 index 00000000000000..81f62671719d50 --- /dev/null +++ b/app/controllers/admin/email_subscriptions/setups_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Admin::EmailSubscriptions::SetupsController < Admin::BaseController + before_action :require_enabled! + + def show + authorize :email_subscription, :enable? + + @form = Form::EmailSubscriptionsConfirmation.new + end + + def create + authorize :email_subscription, :enable? + + @form = Form::EmailSubscriptionsConfirmation.new(resource_params) + + if @form.valid? + Setting.email_subscriptions = true + redirect_to admin_email_subscriptions_path + else + render :show + end + end + + private + + def require_enabled! + raise ActionController::RoutingError, 'Feature disabled' unless Rails.application.config.x.email_subscriptions + end + + def resource_params + params.expect(form_email_subscriptions_confirmation: [:agreement_email_volume, :agreement_privacy_and_terms]) + end +end diff --git a/app/controllers/admin/email_subscriptions_controller.rb b/app/controllers/admin/email_subscriptions_controller.rb new file mode 100644 index 00000000000000..af71eb70123f5f --- /dev/null +++ b/app/controllers/admin/email_subscriptions_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Admin::EmailSubscriptionsController < Admin::BaseController + def index + authorize :email_subscription, :index? + + @enabled = Setting.email_subscriptions + @roles = UserRole.where('permissions & ? != 0', UserRole::FLAGS[:manage_email_subscriptions] | UserRole::FLAGS[:administrator]) + @accounts = Account.local.where.associated(:email_subscriptions).includes(:user) + end + + def disable + authorize :email_subscription, :disable? + Setting.email_subscriptions = false + redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.disabled_msg') + end + + def purge + authorize :email_subscription, :purge? + Admin::EmailSubscriptionsPurgeWorker.perform_async + redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.purged_msg') + end +end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 44ee7206bf7bc7..f12e3da4e1121e 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -16,6 +16,8 @@ def show @report_notes = @report.notes.chronological.includes(:account) @action_logs = @report.history.includes(:target) @form = Admin::StatusBatchAction.new + @collection_form = Admin::CollectionBatchAction.new + @collections = @report.collections @statuses = @report.statuses.with_includes end diff --git a/app/controllers/api/v1/accounts/email_subscriptions_controller.rb b/app/controllers/api/v1/accounts/email_subscriptions_controller.rb index 4e773f902bce83..bf7a1447e1ffca 100644 --- a/app/controllers/api/v1/accounts/email_subscriptions_controller.rb +++ b/app/controllers/api/v1/accounts/email_subscriptions_controller.rb @@ -19,7 +19,7 @@ def set_account end def require_feature_enabled! - head 404 unless Mastodon::Feature.email_subscriptions_enabled? + head 404 unless Rails.application.config.x.email_subscriptions && Setting.email_subscriptions end def require_account_permissions! diff --git a/app/controllers/api/v1/statuses/contexts_controller.rb b/app/controllers/api/v1/statuses/contexts_controller.rb new file mode 100644 index 00000000000000..d2c3d857aba6f5 --- /dev/null +++ b/app/controllers/api/v1/statuses/contexts_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ContextsController < Api::BaseController + include Authorization + include AsyncRefreshesConcern + + before_action -> { authorize_if_got_token! :read, :'read:statuses' } + before_action :set_status + + # This API was originally unlimited, pagination cannot be introduced without + # breaking backwards-compatibility. Arbitrarily high number to cover most + # conversations as quasi-unlimited, it would be too much work to render more + # than this anyway + CONTEXT_LIMIT = 4_096 + + # This remains expensive and we don't want to show everything to logged-out users + ANCESTORS_LIMIT = 40 + DESCENDANTS_LIMIT = 60 + DESCENDANTS_DEPTH_LIMIT = 20 + REFERENCES_LIMIT = 20 + + def show + cache_if_unauthenticated! + + ancestors_limit = CONTEXT_LIMIT + descendants_limit = CONTEXT_LIMIT + descendants_depth_limit = nil + references_limit = CONTEXT_LIMIT + + if current_account.nil? + ancestors_limit = ANCESTORS_LIMIT + descendants_limit = DESCENDANTS_LIMIT + descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT + references_limit = REFERENCES_LIMIT + end + + ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) + descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) + references_results = @status.readable_references(references_limit, current_account) + loaded_ancestors = preload_collection(ancestors_results, Status) + loaded_descendants = preload_collection(descendants_results, Status) + loaded_references = preload_collection(references_results, Status) + + if params[:with_reference] + loaded_references.reject! { |status| loaded_ancestors.any? { |ancestor| ancestor.id == status.id } } + else + loaded_ancestors = (loaded_ancestors + loaded_references).uniq(&:id) + loaded_references = [] + end + + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants, references: loaded_references) + statuses = [@status] + @context.ancestors + @context.descendants + @context.references + + refresh_key = "context:#{@status.id}:refresh" + async_refresh = AsyncRefresh.new(refresh_key) + + if async_refresh.running? + add_async_refresh_header(async_refresh) + elsif !current_account.nil? && @status.should_fetch_replies? + add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true)) + + WorkerBatch.new.within do |batch| + batch.connect(refresh_key, threshold: 1.0) + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) + end + end + + render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 5f80383dd376f1..acc3ad3be7c955 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -2,14 +2,13 @@ class Api::V1::StatusesController < Api::BaseController include Authorization - include AsyncRefreshesConcern include Api::InteractionPoliciesConcern before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] - before_action :require_user!, except: [:index, :show, :context] + before_action :require_user!, except: [:index, :show] before_action :set_statuses, only: [:index] - before_action :set_status, only: [:show, :context] + before_action :set_status, only: [:show] before_action :set_thread, only: [:create] before_action :set_quoted_status, only: [:create] before_action :check_statuses_limit, only: [:index] @@ -17,18 +16,6 @@ class Api::V1::StatusesController < Api::BaseController override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :update, family: :statuses - # This API was originally unlimited, pagination cannot be introduced without - # breaking backwards-compatibility. Arbitrarily high number to cover most - # conversations as quasi-unlimited, it would be too much work to render more - # than this anyway - CONTEXT_LIMIT = 4_096 - - # This remains expensive and we don't want to show everything to logged-out users - ANCESTORS_LIMIT = 40 - DESCENDANTS_LIMIT = 60 - DESCENDANTS_DEPTH_LIMIT = 20 - REFERENCES_LIMIT = 20 - def index @statuses = preload_collection(@statuses, Status) render json: @statuses, each_serializer: REST::StatusSerializer @@ -40,55 +27,6 @@ def show render json: @status, serializer: REST::StatusSerializer end - def context - cache_if_unauthenticated! - - ancestors_limit = CONTEXT_LIMIT - descendants_limit = CONTEXT_LIMIT - descendants_depth_limit = nil - references_limit = CONTEXT_LIMIT - - if current_account.nil? - ancestors_limit = ANCESTORS_LIMIT - descendants_limit = DESCENDANTS_LIMIT - descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT - references_limit = REFERENCES_LIMIT - end - - ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account) - descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit) - references_results = @status.readable_references(references_limit, current_account) - loaded_ancestors = preload_collection(ancestors_results, Status) - loaded_descendants = preload_collection(descendants_results, Status) - loaded_references = preload_collection(references_results, Status) - - if params[:with_reference] - loaded_references.reject! { |status| loaded_ancestors.any? { |ancestor| ancestor.id == status.id } } - else - loaded_ancestors = (loaded_ancestors + loaded_references).uniq(&:id) - loaded_references = [] - end - - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants, references: loaded_references) - statuses = [@status] + @context.ancestors + @context.descendants + @context.references - - refresh_key = "context:#{@status.id}:refresh" - async_refresh = AsyncRefresh.new(refresh_key) - - if async_refresh.running? - add_async_refresh_header(async_refresh) - elsif !current_account.nil? && @status.should_fetch_replies? - add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true)) - - WorkerBatch.new.within do |batch| - batch.connect(refresh_key, threshold: 1.0) - ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) - end - end - - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) - end - def create @status = PostStatusService.new.call( current_user.account, diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index 1ca1cd6923f0a9..4e15b65ff58a2c 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -28,9 +28,10 @@ def index cache_if_unauthenticated! authorize @account, :index_collections? - render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json + presenter = CollectionsPresenter.new(collections: @collections) + render json: presenter, serializer: REST::CollectionsWithAccountPreviewsSerializer rescue Mastodon::NotPermittedError - render json: { collections: [] } + render json: { collections: [], partial_accounts: [] } end def show @@ -73,6 +74,7 @@ def set_account def set_collections @collections = @account.collections .with_tag + .preload(top_items: :account) .order(created_at: :desc) .offset(offset_param) .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index 5b98914114473e..21fa8d4fe1b879 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -3,7 +3,7 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController def show expires_in 1.month, public: true - render content_type: 'text/css' + render content_type: :css end private diff --git a/app/controllers/redirect/collections_controller.rb b/app/controllers/redirect/collections_controller.rb new file mode 100644 index 00000000000000..f5e177d102366c --- /dev/null +++ b/app/controllers/redirect/collections_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Redirect::CollectionsController < Redirect::BaseController + private + + def set_resource + @resource = Collection.find(params[:id]) + not_found if @resource.local? || @resource&.account&.suspended? + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index aae33e2832b05a..b5c7ef9a48a3bb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -129,6 +129,10 @@ def material_symbol(icon, attributes = {}) ) end + def emptyphaunt + inline_svg_tag 'elephant_ui.svg' + end + def check_icon inline_svg_tag 'check.svg' end diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 634fd245541459..0edf8d75075b6c 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -59,9 +59,9 @@ module ContextHelper }, quote_authorizations: { 'gts' => 'https://gotosocial.org/ns#', - 'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' }, - 'interactingObject' => { '@id' => 'gts:interactingObject' }, - 'interactionTarget' => { '@id' => 'gts:interactionTarget' }, + 'QuoteAuthorization' => 'https://w3id.org/fep/044f#QuoteAuthorization', + 'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' }, + 'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' }, }, }.freeze diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 2e331629e4895a..f833f51ff9537e 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -3,6 +3,8 @@ module JsonLdHelper include ContextHelper + UNSUPPORTED_JSONLD_KEYWORDS = %w(@graph @included @reverse).freeze + def equals_or_includes?(haystack, needle) haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle end @@ -118,6 +120,16 @@ def compact(json) compacted end + def unsupported_jsonld_features?(json) + if json.is_a?(Hash) + json.any? { |key, value| UNSUPPORTED_JSONLD_KEYWORDS.include?(key) || unsupported_jsonld_features?(value) } + elsif json.is_a?(Array) + json.any? { |value| unsupported_jsonld_features?(value) } + else + false + end + end + # Patches a JSON-LD document to avoid compatibility issues on redistribution # # Since compacting a JSON-LD document against Mastodon's built-in vocabulary diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index cbf5638ae4edd4..bc4287c72482e2 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -223,7 +223,14 @@ module LanguagesHelper 'zh-YUE': ['Cantonese', '廣東話'].freeze, }.freeze - SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).freeze + # Since nan is not translated but nan-TW is translated, + # to enable the ISO-639-3 language-code with the regional variant but no + # official name, we use a specific hash for nan-TW + ISO_639_3_REGIONAL = { + 'nan-TW': ['Hokkien (Taiwan)', '臺語 (Hô-ló話)'].freeze, + }.freeze + + SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).merge(ISO_639_3_REGIONAL).freeze # For ISO-639-1 and ISO-639-3 language codes, we have their official # names, but for some translations, we need the names of the @@ -233,7 +240,6 @@ 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/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index 0331ca83ba5d15..f2964729f41a70 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -69,8 +69,9 @@ on('change', '#batch_checkbox_all', ({ target }) => { '.batch-table__select-all', ); - document - .querySelectorAll(batchCheckboxClassName) + target + .closest('.batch-table') + ?.querySelectorAll(batchCheckboxClassName) .forEach((content) => { content.checked = target.checked; }); @@ -112,17 +113,20 @@ on('click', '.batch-table__select-all button', () => { } }); -on('change', batchCheckboxClassName, () => { - const checkAllElement = document.querySelector( +on('change', batchCheckboxClassName, (event) => { + const targetTable = (event.target as HTMLElement).closest('.batch-table'); + if (!targetTable) return; + + const checkAllElement = targetTable.querySelector( 'input#batch_checkbox_all', ); - const selectAllMatchingElement = document.querySelector( + const selectAllMatchingElement = targetTable.querySelector( '.batch-table__select-all', ); if (checkAllElement) { const allCheckboxes = Array.from( - document.querySelectorAll(batchCheckboxClassName), + targetTable.querySelectorAll(batchCheckboxClassName), ); checkAllElement.checked = allCheckboxes.every((content) => content.checked); checkAllElement.indeterminate = diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 8b67698f20e24a..4e5b35b97349a2 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -13,14 +13,17 @@ import axios from 'axios'; import { on } from 'delegated-events'; import { throttle } from 'lodash'; +import { determineEmojiMode } from '@/mastodon/features/emoji/mode'; +import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render'; +import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions'; +import { loadLocale, getLocale } from '@/mastodon/locales'; +import { loadPolyfills } from '@/mastodon/polyfills'; +import ready from '@/mastodon/ready'; +import { assetHost } from '@/mastodon/utils/config'; +import { getNestedProperty } from '@/mastodon/utils/objects'; +import { isDarkMode } from '@/mastodon/utils/theme'; import { formatTime } from '@/mastodon/utils/time'; -import emojify from '../mastodon/features/emoji/emoji'; -import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; -import { loadLocale, getLocale } from '../mastodon/locales'; -import { loadPolyfills } from '../mastodon/polyfills'; -import ready from '../mastodon/ready'; - import 'cocoon-js-vanilla'; const messages = defineMessages({ @@ -38,7 +41,7 @@ const messages = defineMessages({ }, }); -function loaded() { +async function loaded() { const { messages: localeData } = getLocale(); const locale = document.documentElement.lang; @@ -75,9 +78,30 @@ function loaded() { return messageFormat.format(values) as string; }; - document.querySelectorAll('.emojify').forEach((content) => { - content.innerHTML = emojify(content.innerHTML); - }); + let emojiStyle = 'auto'; + const initialStateText = + document.getElementById('initial-state')?.textContent; + if (initialStateText) { + const stateEmojiStyle = getNestedProperty( + JSON.parse(initialStateText) as unknown, + 'meta', + 'emoji_style', + ); + if (typeof stateEmojiStyle === 'string') { + emojiStyle = stateEmojiStyle; + } + } + const emojiMode = determineEmojiMode(emojiStyle); + const darkTheme = isDarkMode(); + for (const element of document.querySelectorAll('.emojify')) { + await updateHtmlWithEmoji({ + assetHost, + element, + locale, + mode: emojiMode, + darkTheme, + }); + } document .querySelectorAll('time.formatted') diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1762d8e043e241..3f08c068693d7c 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -6,9 +6,8 @@ import { throttle } from 'lodash'; import api from 'mastodon/api'; import { browserHistory } from 'mastodon/components/router'; import { countableText } from 'mastodon/features/compose/util/counter'; -import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'mastodon/settings'; -import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker'; +import { emojiMartSearch } from '@/mastodon/features/emoji/picker'; import { showAlert, showAlertForError } from './alerts'; import { useEmoji } from './emojis'; @@ -610,8 +609,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => { }, 200, { leading: true, trailing: true }); const fetchComposeSuggestionsEmojis = async (dispatch, token) => { - const custom = await fetchCustomEmojiData(); - const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom }); + // Right now we are hard-coding the locale to English since the picker search only supports English. + // Once we replace the legacy picker we can remove this and use the actual locale of the user. + const results = await emojiMartSearch(token, 'en', 5); dispatch(readyComposeSuggestionsEmojis(token, results)); }; @@ -689,7 +689,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) { let completion, startPosition; if (suggestion.type === 'emoji') { - completion = suggestion.native || suggestion.colons; + completion = suggestion.native || `:${suggestion.id}:`; startPosition = position - 1; dispatch(useEmoji(suggestion)); diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap deleted file mode 100644 index 9502accb7360b6..00000000000000 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap +++ /dev/null @@ -1,35 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders emoji with custom url 1`] = ` -
- foobar -
- :foobar: -
-
-`; - -exports[` > renders native emoji 1`] = ` -
- 💙 -
- :foobar: -
-
-`; diff --git a/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx b/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx deleted file mode 100644 index 2603420aec4d2e..00000000000000 --- a/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import renderer from 'react-test-renderer'; - -import AutosuggestEmoji from '../autosuggest_emoji'; - -describe('', () => { - it('renders native emoji', () => { - const emoji = { - native: '💙', - colons: ':foobar:', - }; - const component = renderer.create(); - const tree = component.toJSON(); - - expect(tree).toMatchSnapshot(); - }); - - it('renders emoji with custom url', () => { - const emoji = { - custom: true, - imageUrl: 'http://example.com/emoji.png', - native: 'foobar', - colons: ':foobar:', - }; - const component = renderer.create(); - const tree = component.toJSON(); - - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/app/javascript/mastodon/components/account_header/banners.tsx b/app/javascript/mastodon/components/account_header/banners.tsx new file mode 100644 index 00000000000000..269f65b8d19a1e --- /dev/null +++ b/app/javascript/mastodon/components/account_header/banners.tsx @@ -0,0 +1,140 @@ +import { useCallback } from 'react'; +import type { FC, ReactElement, ReactNode } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { + authorizeFollowRequest, + rejectFollowRequest, +} from '@/mastodon/actions/accounts'; +import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; +import { useRelationship } from '@/mastodon/hooks/useRelationship'; +import type { Account } from '@/mastodon/models/account'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; + +import { AvatarOverlay } from '../avatar_overlay'; +import { Button } from '../button'; +import { DisplayName } from '../display_name'; +import { Icon } from '../icon'; + +import classes from './styles.module.scss'; + +export const AccountBanners: FC<{ account: Account }> = ({ account }) => { + const { suspended, hidden } = useAccountVisibility(account.id); + const relationship = useRelationship(account.id); + + if (hidden) { + return null; + } + + let banner: ReactNode = null; + + if (account.memorial) { + banner = ( + + + + ); + } + + if (account.moved) { + banner = ; + } + + if (!suspended && relationship?.requested_by) { + banner = ; + } + + if (!banner) { + return null; + } + + return
{banner}
; +}; + +const FollowRequestNote: FC<{ account: Account }> = ({ account }) => { + const accountId = account.id; + const dispatch = useAppDispatch(); + const handleAuthorize = useCallback(() => { + dispatch(authorizeFollowRequest(accountId)); + }, [accountId, dispatch]); + const handleReject = useCallback(() => { + dispatch(rejectFollowRequest(accountId)); + }, [accountId, dispatch]); + + return ( + <> + + }} + /> + + +
+ + + +
+ + ); +}; + +const MovedNote: React.FC<{ + account: Account; + targetAccountId: string; +}> = ({ account: from, targetAccountId }) => { + const to = useAppSelector((state) => state.accounts.get(targetAccountId)); + + return ( + <> + + , + }} + /> + + +
+ + + + + + + + +
+ + ); +}; + +const MessageText: React.FC<{ children: ReactElement }> = ({ children }) => ( +
{children}
+); diff --git a/app/javascript/mastodon/components/account_header/buttons.tsx b/app/javascript/mastodon/components/account_header/buttons.tsx index 737a3b9ca5a302..7a5ca4332cd300 100644 --- a/app/javascript/mastodon/components/account_header/buttons.tsx +++ b/app/javascript/mastodon/components/account_header/buttons.tsx @@ -3,8 +3,6 @@ import type { FC } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import classNames from 'classnames'; - import { followAccount } from '@/mastodon/actions/accounts'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { getAccountHidden } from '@/mastodon/selectors/accounts'; @@ -18,6 +16,7 @@ import { FollowButton } from '../follow_button'; import { IconButton } from '../icon_button'; import { AccountMenu } from './menu'; +import classes from './styles.module.scss'; const messages = defineMessages({ enableNotifications: { @@ -49,7 +48,7 @@ export const AccountButtons: FC = ({ const me = useAppSelector((state) => state.meta.get('me') as string); return ( -
+
{!hidden && ( )} @@ -94,7 +93,7 @@ const AccountButtonsOther: FC< {!isMovedAndUnfollowedAccount && ( )} diff --git a/app/javascript/mastodon/components/account_header/fields.tsx b/app/javascript/mastodon/components/account_header/fields.tsx index ce2fe9d3225d1a..d03ce52c7fcac3 100644 --- a/app/javascript/mastodon/components/account_header/fields.tsx +++ b/app/javascript/mastodon/components/account_header/fields.tsx @@ -108,11 +108,9 @@ const FieldCard: FC<{ }> = ({ htmlHandlers, field }) => { const intl = useIntl(); const { - name, name_emojified, nameHasEmojis, value_emojified, - value_plain, valueHasEmojis, verified_at, } = field; @@ -138,8 +136,7 @@ const FieldCard: FC<{ )} label={ void; @@ -183,9 +178,7 @@ type FieldHTMLProps = { const FieldHTML: FC = ({ className, - extraEmojis, text, - textEmojified, textHasCustomEmoji, isOverflowing, onOverflowClick, @@ -198,7 +191,7 @@ const FieldHTML: FC = ({ const html = ( = ({ accountId, hideTabs }) => { const dispatch = useAppDispatch(); const account = useAppSelector((state) => state.accounts.get(accountId)); - const relationship = useAppSelector((state) => - state.relationships.get(accountId), - ); const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); const handleOpenAvatar = useCallback( @@ -96,22 +92,13 @@ export const AccountHeader: React.FC<{ const isMe = me && account.id === me; return ( -
- {!hidden && account.memorial && } - {!hidden && account.moved && ( - - )} +
+ - {!suspendedOrHidden && !account.moved && relationship?.requested_by && ( - - )} - -
+
{!suspendedOrHidden && ( -
-
+
+ -
+
-
- {me && account.id !== me && ( - - )} - - - - -
+
+ {me && account.id !== me && } + + + + {!me && account.email_subscriptions && ( diff --git a/app/javascript/mastodon/components/account_header/memorial_note.tsx b/app/javascript/mastodon/components/account_header/memorial_note.tsx deleted file mode 100644 index 19e6f0ed2252fb..00000000000000 --- a/app/javascript/mastodon/components/account_header/memorial_note.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -export const MemorialNote: React.FC = () => ( -
-
- -
-
-); diff --git a/app/javascript/mastodon/components/account_header/menu.tsx b/app/javascript/mastodon/components/account_header/menu.tsx index a8cd2677fc2bc2..8be090cc5c536b 100644 --- a/app/javascript/mastodon/components/account_header/menu.tsx +++ b/app/javascript/mastodon/components/account_header/menu.tsx @@ -21,6 +21,10 @@ import { import { openModal } from '@/mastodon/actions/modal'; import { initMuteModal } from '@/mastodon/actions/mutes'; import { initReport } from '@/mastodon/actions/reports'; +import { + canAccountBeAdded, + canAccountBeAddedByFollowers, +} from '@/mastodon/features/collections/utils'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useIdentity } from '@/mastodon/identity_context'; import type { Account } from '@/mastodon/models/account'; @@ -226,6 +230,10 @@ const redesignMessages = defineMessages({ id: 'account.menu.add_to_list', defaultMessage: 'Add to list…', }, + addToCollection: { + id: 'account.menu.add_to_collection', + defaultMessage: 'Add to collection…', + }, openOriginalPage: { id: 'account.menu.open_original_page', defaultMessage: 'View on {domain}', @@ -306,35 +314,57 @@ function getMenuItems({ return items; } - // List and featuring options + // Add to list if (relationship?.following) { - items.push( - { - text: intl.formatMessage(redesignMessages.addToList), - action: () => { - dispatch( - openModal({ - modalType: 'LIST_ADDER', - modalProps: { - accountId: account.id, - }, - }), - ); - }, + items.push({ + text: intl.formatMessage(redesignMessages.addToList), + action: () => { + dispatch( + openModal({ + modalType: 'LIST_ADDER', + modalProps: { + accountId: account.id, + }, + }), + ); }, - { - text: intl.formatMessage( - relationship.endorsed ? messages.unendorse : messages.endorse, - ), - action: () => { - if (relationship.endorsed) { - dispatch(unpinAccount(account.id)); - } else { - dispatch(pinAccount(account.id)); - } - }, + }); + } + + // Add to collection + if ( + canAccountBeAdded(account) || + (canAccountBeAddedByFollowers(account) && relationship?.following) + ) { + items.push({ + text: intl.formatMessage(redesignMessages.addToCollection), + action: () => { + dispatch( + openModal({ + modalType: 'COLLECTION_ADDER', + modalProps: { + accountId: account.id, + }, + }), + ); }, - ); + }); + } + + // Feature on profile + if (relationship?.following) { + items.push({ + text: intl.formatMessage( + relationship.endorsed ? messages.unendorse : messages.endorse, + ), + action: () => { + if (relationship.endorsed) { + dispatch(unpinAccount(account.id)); + } else { + dispatch(pinAccount(account.id)); + } + }, + }); } items.push( diff --git a/app/javascript/mastodon/components/account_header/moved_note.tsx b/app/javascript/mastodon/components/account_header/moved_note.tsx deleted file mode 100644 index 153c206bbc96c5..00000000000000 --- a/app/javascript/mastodon/components/account_header/moved_note.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import { useAppSelector } from '@/mastodon/store'; - -import { AvatarOverlay } from '../avatar_overlay'; -import { DisplayName } from '../display_name'; - -export const MovedNote: React.FC<{ - accountId: string; - targetAccountId: string; -}> = ({ accountId, targetAccountId }) => { - const from = useAppSelector((state) => state.accounts.get(accountId)); - const to = useAppSelector((state) => state.accounts.get(targetAccountId)); - - return ( -
-
- , - }} - /> -
- -
- -
- -
- - - - - - -
-
- ); -}; diff --git a/app/javascript/mastodon/components/account_header/number_fields.tsx b/app/javascript/mastodon/components/account_header/number_fields.tsx index 4d539e8449994b..db737d27334bc1 100644 --- a/app/javascript/mastodon/components/account_header/number_fields.tsx +++ b/app/javascript/mastodon/components/account_header/number_fields.tsx @@ -11,6 +11,8 @@ import { FormattedDateWrapper } from '../formatted_date'; import { NumberFields, NumberFieldsItem } from '../number_fields'; import { ShortNumber } from '../short_number'; +import classes from './styles.module.scss'; + export const AccountNumberFields: FC<{ accountId: string }> = ({ accountId, }) => { @@ -33,7 +35,7 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({ } return ( - + diff --git a/app/javascript/mastodon/components/account_header/styles.module.scss b/app/javascript/mastodon/components/account_header/styles.module.scss index 7d87dde147560a..2daf3867346011 100644 --- a/app/javascript/mastodon/components/account_header/styles.module.scss +++ b/app/javascript/mastodon/components/account_header/styles.module.scss @@ -1,30 +1,85 @@ +.moved { + opacity: 0.5; +} + +// Account header .header { height: 120px; + overflow: hidden; background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border-primary); + + img { + object-fit: cover; + display: block; + width: 100%; + height: 100%; + margin: 0; + } @container (width >= 500px) { height: 160px; } + + .moved & { + filter: grayscale(100%); + } } +// Wraps everything except the header image. .barWrapper { - border-bottom: none; padding-inline: 16px; } +// Avatar .avatarWrapper { margin-top: -64px; padding-top: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + overflow: hidden; + margin-inline-start: -2px; // aligns the pfp with content below +} + +.avatar { + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--avatar-border-radius); + + .moved & { + filter: grayscale(100%); + } } .displayNameWrapper { display: flex; align-items: start; gap: 16px; + margin-top: 16px; + margin-bottom: 16px; + + h1 { + font-size: 17px; + line-height: 22px; + color: var(--color-text-primary); + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + :global(.emojione) { + width: 22px; + height: 22px; + } } .nameWrapper { flex-grow: 1; + min-width: 0; + overflow-wrap: break-word; } .name { @@ -112,21 +167,6 @@ } } -$button-breakpoint: 420px; -$button-fallback-breakpoint: $button-breakpoint + 55px; - -.buttonsDesktop { - @container (width < #{$button-breakpoint}) { - display: none; - } - - @supports (not (container-type: inline-size)) { - @media (max-width: #{$button-fallback-breakpoint}) { - display: none; - } - } -} - .handleCopy { border: 1px solid var(--color-border-primary); border-radius: 8px; @@ -147,6 +187,45 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; } } +$button-breakpoint: 420px; +$button-fallback-breakpoint: $button-breakpoint + 55px; + +.buttonsDesktop, +.buttonsMobile { + display: flex; + align-items: center; + gap: 8px; + + :global(.button) { + flex-shrink: 1; + white-space: nowrap; + min-width: 80px; + } + + :global(.icon-button) { + border: 1px solid var(--color-border-primary); + border-radius: 4px; + box-sizing: content-box; + padding: 5px; + + &:global(.copied) { + border-color: var(--color-text-success); + } + } +} + +.buttonsDesktop { + @container (width < #{$button-breakpoint}) { + display: none; + } + + @supports (not (container-type: inline-size)) { + @media (max-width: #{$button-fallback-breakpoint}) { + display: none; + } + } +} + .buttonsMobile { position: sticky; bottom: var(--mobile-bottom-nav-height); @@ -186,8 +265,32 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; } } +.numberFields { + @container (width >= #{$button-breakpoint}) { + --number-fields-gap: 40px; + } +} + .bio { font-size: 15px; + color: var(--color-text-primary); + unicode-bidi: plaintext; + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + :any-link { + color: var(--color-text-status-links); + + &:hover { + text-decoration: none; + } + } } .familiarFollowers { @@ -200,9 +303,6 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; .noteContent { white-space-collapse: preserve-breaks; - overflow-wrap: break-word; - word-break: break-all; - hyphens: auto; } .noteEditButton { @@ -358,13 +458,24 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; border-bottom: 1px solid var(--color-border-primary); } +// Banners + +.bannerWrapper, .bannerBase { box-sizing: border-box; + display: flex; + flex-direction: column; +} + +.bannerWrapper { + background: var(--color-bg-tertiary); padding: 16px; + align-items: center; +} + +.bannerBase { border-radius: 12px; background: var(--color-bg-secondary); - display: flex; - flex-direction: column; gap: 12px; justify-content: center; align-items: flex-start; @@ -380,6 +491,13 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; } } +.bannerText { + color: var(--color-text-secondary); + font-size: 14px; + font-weight: 500; + text-align: center; +} + .bannerTextAndActions { display: flex; flex-direction: column; @@ -394,6 +512,19 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; } } +.bannerActions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; + width: 100%; + margin-top: 16px; + + button { + width: 100%; + } +} + .bannerDisclaimer { a { color: inherit; @@ -423,3 +554,39 @@ $button-fallback-breakpoint: $button-breakpoint + 55px; background: var(--color-bg-primary); } } + +.bannerActionsDisplayName { + color: var(--color-text-secondary); + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + line-height: 22px; + overflow: hidden; + text-decoration: none; + + &:hover strong { + text-decoration: underline; + } + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + strong { + color: var(--color-text-primary); + } +} + +// Buttons + +.followButton { + flex-grow: 1; +} + +.bioButtonsWrapper { + margin-top: 16px; +} diff --git a/app/javascript/mastodon/components/account_header/subscription_form.tsx b/app/javascript/mastodon/components/account_header/subscription_form.tsx index f78077bef76910..e09e7d4b1451b2 100644 --- a/app/javascript/mastodon/components/account_header/subscription_form.tsx +++ b/app/javascript/mastodon/components/account_header/subscription_form.tsx @@ -104,8 +104,6 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({ .then(() => { setSubmitting(false); setSubmitted(true); - - return ''; }) .catch((err: unknown) => { setSubmitting(false); @@ -133,12 +131,11 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({ className={classNames(classes.bannerBase, classes.bannerBaseCentered)} >
-

- -

+ = ({ return (
-

- , - }} - /> -

+ , + }} + />
diff --git a/app/javascript/mastodon/components/account_list_item/index.tsx b/app/javascript/mastodon/components/account_list_item/index.tsx index 04817cb33e2f90..d6da056c8c952f 100644 --- a/app/javascript/mastodon/components/account_list_item/index.tsx +++ b/app/javascript/mastodon/components/account_list_item/index.tsx @@ -90,7 +90,7 @@ export const AccountListItem: React.FC = ({ {handle}} > - {emoji.native - -
{emoji.colons}
-
- ); - } - -} diff --git a/app/javascript/mastodon/components/autosuggest_emoji.tsx b/app/javascript/mastodon/components/autosuggest_emoji.tsx new file mode 100644 index 00000000000000..ff591fe3d9c1d5 --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_emoji.tsx @@ -0,0 +1,20 @@ +import type { FC } from 'react'; + +import { Emoji } from './emoji'; + +interface LegacyEmoji { + id: string; + custom?: boolean; + native?: string; + imageUrl?: string; +} + +export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => { + const colons = `:${emoji.id}:`; + return ( +
+ +
{colons}
+
+ ); +}; diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index 9e342a353a169e..4a3b7d904001a6 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -9,8 +9,9 @@ import Overlay from 'react-overlays/Overlay'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; +import { LocalCustomEmojiProvider } from './emoji/context'; const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { let word; @@ -219,15 +220,17 @@ export default class AutosuggestInput extends ImmutablePureComponent { spellCheck={spellCheck} /> - - {({ props }) => ( -
-
- {suggestions.map(this.renderSuggestion)} + + + {({ props }) => ( +
+
+ {suggestions.map(this.renderSuggestion)} +
-
- )} - + )} + +
); } diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index fae078da31b379..fb99f62a73d260 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -10,8 +10,9 @@ import Textarea from 'react-textarea-autosize'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; +import { LocalCustomEmojiProvider } from './emoji/context'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; @@ -218,15 +219,17 @@ const AutosuggestTextarea = forwardRef(({ lang={lang} /> - - {({ props }) => ( -
-
- {suggestions.map(renderSuggestion)} + + + {({ props }) => ( +
+
+ {suggestions.map(renderSuggestion)} +
-
- )} - + )} + +
); }); diff --git a/app/javascript/mastodon/components/button/button.stories.tsx b/app/javascript/mastodon/components/button/button.stories.tsx index 6827097c50e415..cb60ca3296dd98 100644 --- a/app/javascript/mastodon/components/button/button.stories.tsx +++ b/app/javascript/mastodon/components/button/button.stories.tsx @@ -74,6 +74,24 @@ export const Compact: Story = { play: buttonTest, }; +export const CompactSecondary: Story = { + args: { + compact: true, + secondary: true, + children: 'Compact secondary button', + }, + play: buttonTest, +}; + +export const CompactPlain: Story = { + args: { + compact: true, + plain: true, + children: 'Compact plain button', + }, + play: buttonTest, +}; + export const Dangerous: Story = { args: { dangerous: true, diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css index f7fbce491bb1b4..fc05e57ab3a86c 100644 --- a/app/javascript/mastodon/components/callout/styles.module.css +++ b/app/javascript/mastodon/components/callout/styles.module.css @@ -17,6 +17,11 @@ margin-top: -2px; } +.content, +.body { + min-width: 0; +} + .content { display: flex; gap: 8px; @@ -32,6 +37,8 @@ .body { flex-grow: 1; + overflow-wrap: break-word; + hyphens: auto; a { color: inherit; diff --git a/app/javascript/mastodon/components/domain.tsx b/app/javascript/mastodon/components/domain.tsx index 0ccffac4829056..86f8a0e6a3af22 100644 --- a/app/javascript/mastodon/components/domain.tsx +++ b/app/javascript/mastodon/components/domain.tsx @@ -9,12 +9,14 @@ import { Button } from './button'; export const Domain: React.FC<{ domain: string; -}> = ({ domain }) => { + onUnblock?: (domain: string) => void; +}> = ({ domain, onUnblock }) => { const dispatch = useAppDispatch(); const handleDomainUnblock = useCallback(() => { dispatch(unblockDomain(domain)); - }, [dispatch, domain]); + onUnblock?.(domain); + }, [dispatch, domain, onUnblock]); return (
diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index b9686fc0f91397..fd86c59e30e2ad 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -90,7 +90,7 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ ); }; -export const DropdownMenu = ({ +export const DropdownMenu = ({ items, loading, scrollable, diff --git a/app/javascript/mastodon/components/emoji/context.tsx b/app/javascript/mastodon/components/emoji/context.tsx index ceed1d1ff6597b..b4a364df873128 100644 --- a/app/javascript/mastodon/components/emoji/context.tsx +++ b/app/javascript/mastodon/components/emoji/context.tsx @@ -1,4 +1,9 @@ -import type { MouseEventHandler, PropsWithChildren } from 'react'; +import type { + FC, + MouseEventHandler, + PropsWithChildren, + ReactNode, +} from 'react'; import { createContext, useCallback, @@ -8,6 +13,7 @@ import { } from 'react'; import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize'; +import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis'; import { autoPlayGif } from '@/mastodon/initial_state'; import { polymorphicForwardRef } from '@/types/polymorphic'; import type { @@ -103,3 +109,10 @@ export const CustomEmojiProvider = ({ ); }; + +export const LocalCustomEmojiProvider: FC<{ children: ReactNode }> = ({ + children, +}) => { + const emojis = useCustomEmojis(); + return {children}; +}; diff --git a/app/javascript/mastodon/components/emoji_view.jsx b/app/javascript/mastodon/components/emoji_view.jsx deleted file mode 100644 index 39e28b2673b356..00000000000000 --- a/app/javascript/mastodon/components/emoji_view.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import emojify from '../features/emoji/emoji'; - -export default class EmojiView extends PureComponent { - - static propTypes = { - name: PropTypes.string, - url: PropTypes.string, - staticUrl: PropTypes.string, - }; - - render () { - const { name, url, staticUrl } = this.props; - - let emojiHtml = null; - if (url) { - let customEmojis = {}; - customEmojis[`:${name}:`] = { url, static_url: staticUrl }; - emojiHtml = emojify(`:${name}:`, customEmojis); - } else { - emojiHtml = emojify(name); - } - - return ( - - ); - } - -} diff --git a/app/javascript/mastodon/components/emoji_view.tsx b/app/javascript/mastodon/components/emoji_view.tsx new file mode 100644 index 00000000000000..eebecafc426bd5 --- /dev/null +++ b/app/javascript/mastodon/components/emoji_view.tsx @@ -0,0 +1,29 @@ +import type { CustomEmojiMapArg } from '../features/emoji/types'; + +import { EmojiHTML } from './emoji/html'; + +interface EmojiViewProps { + name: string; + url?: string; + staticUrl?: string; +} + +export const EmojiView: React.FC = ({ + name, + url, + staticUrl, +}) => { + if (url && staticUrl) { + const extraEmojis: CustomEmojiMapArg = [ + { + shortcode: name, + static_url: staticUrl, + url, + visible_in_picker: false, + }, + ]; + return ; + } + + return ; +}; diff --git a/app/javascript/mastodon/components/empty_state/empty_state.module.scss b/app/javascript/mastodon/components/empty_state/empty_state.module.scss index 96aea81d1ccedd..64d9a4e584e937 100644 --- a/app/javascript/mastodon/components/empty_state/empty_state.module.scss +++ b/app/javascript/mastodon/components/empty_state/empty_state.module.scss @@ -35,6 +35,10 @@ color: var(--color-text-secondary); text-wrap: pretty; } + + a { + color: var(--color-text-status-links); + } } [data-color-scheme='dark'] .defaultImage { diff --git a/app/javascript/mastodon/components/error_boundary.jsx b/app/javascript/mastodon/components/error_boundary.jsx index ca2f017f3b2ac4..54424d283bc4f1 100644 --- a/app/javascript/mastodon/components/error_boundary.jsx +++ b/app/javascript/mastodon/components/error_boundary.jsx @@ -3,7 +3,7 @@ import { PureComponent } from 'react'; import { FormattedMessage } from 'react-intl'; -import { Helmet } from 'react-helmet'; +import { Helmet } from '@unhead/react/helmet'; import StackTrace from 'stacktrace-js'; diff --git a/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss b/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss index cff93be8a69fcd..43b672a2a140e7 100644 --- a/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss +++ b/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss @@ -48,10 +48,14 @@ .status { // If there's no content, we need to compensate for the parent's - // flex gap to avoid extra spacing below the field. + // flex gap to avoid extra spacing below or next to the field. &:empty { margin-top: calc(-1 * var(--form-field-label-gap)); } + + [data-input-placement^='inline'] &:empty { + margin-inline-start: calc(-1 * var(--form-field-label-gap)); + } } .inputWrapper { diff --git a/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx b/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx index ffa7f491bcfa1d..579d6e814ca899 100644 --- a/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx +++ b/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx @@ -24,7 +24,7 @@ export interface FieldStatus { message?: string; } -interface FieldWrapperProps { +export interface FieldWrapperProps { label: ReactNode; hint?: ReactNode; required?: boolean; diff --git a/app/javascript/mastodon/components/form_fields/select_field.tsx b/app/javascript/mastodon/components/form_fields/select_field.tsx index 7c1bfdf47d93db..f2e9e5c8d350e6 100644 --- a/app/javascript/mastodon/components/form_fields/select_field.tsx +++ b/app/javascript/mastodon/components/form_fields/select_field.tsx @@ -4,11 +4,17 @@ import { forwardRef } from 'react'; import classNames from 'classnames'; import { FormFieldWrapper } from './form_field_wrapper'; -import type { CommonFieldWrapperProps } from './form_field_wrapper'; +import type { + CommonFieldWrapperProps, + FieldWrapperProps, +} from './form_field_wrapper'; import classes from './select.module.scss'; interface Props - extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {} + extends + ComponentPropsWithoutRef<'select'>, + CommonFieldWrapperProps, + Pick {} /** * A simple form field for single-item selections. @@ -19,13 +25,28 @@ interface Props */ export const SelectField = forwardRef( - ({ id, label, hint, required, status, children, ...otherProps }, ref) => ( + ( + { + id, + label, + hint, + required, + status, + inputPlacement, + children, + wrapperClassName, + ...otherProps + }, + ref, + ) => ( {(inputProps) => (