diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 217415c528bf97..d303c5c3c59265 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -56,7 +56,7 @@ services: - internal_network es: - image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2 + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29 restart: unless-stopped environment: ES_JAVA_OPTS: -Xms512m -Xmx512m diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 795cbb93f1aaec..8043001f910378 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -12,7 +12,6 @@ on: - 'package.json' - 'yarn.lock' - '.nvmrc' - - '.prettier*' - 'stylelint.config.js' - '**/*.css' - '**/*.scss' @@ -24,7 +23,6 @@ on: - 'package.json' - 'yarn.lock' - '.nvmrc' - - '.prettier*' - 'stylelint.config.js' - '**/*.css' - '**/*.scss' diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 33108aba20053a..d905014a62c257 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -13,7 +13,6 @@ on: - 'yarn.lock' - 'tsconfig.json' - '.nvmrc' - - '.prettier*' - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' @@ -27,7 +26,6 @@ on: - 'yarn.lock' - 'tsconfig.json' - '.nvmrc' - - '.prettier*' - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 519619fe656f4b..c1e2608c94e3b9 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -357,10 +357,10 @@ jobs: - '3.3' - '.ruby-version' search-image: - - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 + - docker.elastic.co/elasticsearch/elasticsearch:7.17.29 include: - ruby-version: '.ruby-version' - search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 + search-image: docker.elastic.co/elasticsearch/elasticsearch:8.19.2 - ruby-version: '.ruby-version' search-image: opensearchproject/opensearch:2 diff --git a/.haml-lint.yml b/.haml-lint.yml index 7dbc88e9dbd44d..56b190968d403f 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -12,6 +12,6 @@ linters: MiddleDot: enabled: true LineLength: - max: 300 + max: 240 # Override default value of 80 inherited from rubocop ViewLength: max: 200 # Override default value of 100 inherited from rubocop diff --git a/.nvmrc b/.nvmrc index 12fd1fc27773ea..fd655f8a35b4f3 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.13 +24.14 diff --git a/Gemfile b/Gemfile index 9a0e1d609408fd..1ff1ebf7de56df 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ ruby '>= 3.2.0', '< 3.5.0' gem 'propshaft' gem 'puma', '~> 7.0' -gem 'rails', '~> 8.0' +gem 'rails', '~> 8.1.0' gem 'thor', '~> 1.2' gem 'dotenv' @@ -129,9 +129,6 @@ group :test do # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab gem 'rspec-github', '~> 3.0', require: false - # RSpec helpers for email specs - gem 'email_spec' - # Extra RSpec extension methods and helpers for sidekiq gem 'rspec-sidekiq', '~> 5.0' @@ -180,7 +177,7 @@ group :development do # Enhanced error message pages for development gem 'better_errors', '~> 2.9' - gem 'binding_of_caller', '~> 1.0' + gem 'binding_of_caller' # Preview mail in the browser gem 'letter_opener', '~> 1.8' diff --git a/Gemfile.lock b/Gemfile.lock index 8feb1490d03436..4096fb39d1d08f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,31 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (8.0.3) - actionpack (= 8.0.3) - activesupport (= 8.0.3) + action_text-trix (2.1.16) + railties + actioncable (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.3) - actionpack (= 8.0.3) - activejob (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + actionmailbox (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) mail (>= 2.8.0) - actionmailer (8.0.3) - actionpack (= 8.0.3) - actionview (= 8.0.3) - activejob (= 8.0.3) - activesupport (= 8.0.3) + actionmailer (8.1.2) + actionpack (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activesupport (= 8.1.2) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.3) - actionview (= 8.0.3) - activesupport (= 8.0.3) + actionpack (8.1.2) + actionview (= 8.1.2) + activesupport (= 8.1.2) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,15 +42,16 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.3) - actionpack (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + actiontext (8.1.2) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.3) - activesupport (= 8.0.3) + actionview (8.1.2) + activesupport (= 8.1.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -58,35 +61,35 @@ GEM activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (8.0.3) - activesupport (= 8.0.3) + activejob (8.1.2) + activesupport (= 8.1.2) globalid (>= 0.3.6) - activemodel (8.0.3) - activesupport (= 8.0.3) - activerecord (8.0.3) - activemodel (= 8.0.3) - activesupport (= 8.0.3) + activemodel (8.1.2) + activesupport (= 8.1.2) + activerecord (8.1.2) + activemodel (= 8.1.2) + activesupport (= 8.1.2) timeout (>= 0.4.0) - activestorage (8.0.3) - actionpack (= 8.0.3) - activejob (= 8.0.3) - activerecord (= 8.0.3) - activesupport (= 8.0.3) + activestorage (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activesupport (= 8.1.2) marcel (~> 1.0) - activesupport (8.0.3) + activesupport (8.1.2) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.8) + addressable (2.8.9) public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) @@ -96,8 +99,8 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1213.0) - aws-sdk-core (3.242.0) + aws-partitions (1.1222.0) + aws-sdk-core (3.243.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -105,7 +108,7 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.121.0) + aws-sdk-kms (1.122.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.213.0) @@ -126,12 +129,12 @@ GEM rouge (>= 1.0.0) bigdecimal (3.3.1) bindata (2.5.1) - binding_of_caller (1.0.1) + binding_of_caller (2.0.0) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.22.0) + bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.2) + brakeman (8.0.4) racc browser (6.2.0) builder (3.3.0) @@ -221,13 +224,9 @@ GEM base64 faraday (>= 1, < 3) multi_json - email_spec (2.3.0) - htmlentities (~> 4.3.3) - launchy (>= 2.1, < 4.0) - mail (~> 2.7) email_validator (2.2.4) activemodel - erb (6.0.1) + erb (6.0.2) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -291,7 +290,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.69.0) + haml_lint (0.72.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -308,7 +307,7 @@ GEM hiredis-client (0.26.4) redis-client (= 0.26.4) hkdf (0.3.0) - htmlentities (4.3.4) + htmlentities (4.4.2) http (5.3.1) addressable (~> 2.8) http-cookie (~> 1.0) @@ -411,12 +410,12 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) - linzer (0.7.7) - cgi (~> 0.4.2) + linzer (0.7.8) + cgi (>= 0.4.2, < 0.6.0) forwardable (~> 1.3, >= 1.3.3) logger (~> 1.7, >= 1.7.0) - net-http (~> 0.6.0) - openssl (~> 3.0, >= 3.0.0) + net-http (>= 0.6, < 0.10) + openssl (>= 3, < 5) rack (>= 2.2, < 4.0) starry (~> 0.2) stringio (~> 3.1, >= 3.1.2) @@ -447,17 +446,18 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0203) + mime-types-data (3.2026.0224) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) multi_json (1.19.1) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.6.2) + net-imap (0.6.3) date net-protocol net-ldap (0.20.0) @@ -590,7 +590,7 @@ GEM ox (2.14.23) bigdecimal (>= 3.0) parallel (1.27.0) - parser (3.3.10.1) + parser (3.3.10.2) ast (~> 2.4.1) racc parslet (2.0.0) @@ -624,7 +624,7 @@ GEM psych (5.3.1) date stringio - public_suffix (7.0.2) + public_suffix (7.0.5) puma (7.2.0) nio4r (~> 2.0) pundit (2.5.2) @@ -657,33 +657,33 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (8.0.3) - actioncable (= 8.0.3) - actionmailbox (= 8.0.3) - actionmailer (= 8.0.3) - actionpack (= 8.0.3) - actiontext (= 8.0.3) - actionview (= 8.0.3) - activejob (= 8.0.3) - activemodel (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + rails (8.1.2) + actioncable (= 8.1.2) + actionmailbox (= 8.1.2) + actionmailer (= 8.1.2) + actionpack (= 8.1.2) + actiontext (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activemodel (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) bundler (>= 1.15.0) - railties (= 8.0.3) + railties (= 8.1.2) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) - railties (8.0.3) - actionpack (= 8.0.3) - activesupport (= 8.0.3) + railties (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -701,7 +701,7 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (7.1.0) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -792,8 +792,9 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec (~> 3.5) - ruby-prof (1.7.2) + ruby-prof (2.0.2) base64 + ostruct ruby-progressbar (1.13.0) ruby-saml (1.18.1) nokogiri (>= 1.13.10) @@ -887,7 +888,7 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.3) + tzinfo-data (1.2026.1) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext @@ -903,7 +904,7 @@ GEM vite_rails (3.0.20) railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.9.2) + vite_ruby (3.9.3) dry-cli (>= 0.7, < 2) logger (~> 1.6) mutex_m @@ -936,7 +937,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS ruby @@ -948,7 +949,7 @@ DEPENDENCIES aws-sdk-core aws-sdk-s3 (~> 1.123) better_errors (~> 2.9) - binding_of_caller (~> 1.0) + binding_of_caller blurhash (~> 0.1) bootsnap brakeman (~> 8.0) @@ -972,7 +973,6 @@ DEPENDENCIES discard (~> 1.2) doorkeeper (~> 5.6) dotenv - email_spec fabrication faker (~> 3.2) faraday-httpclient @@ -1050,7 +1050,7 @@ DEPENDENCIES rack-attack (~> 6.6) rack-cors rack-test (~> 2.1) - rails (~> 8.0) + rails (~> 8.1.0) rails-i18n (~> 8.0) rdf-normalize (~> 0.5) redcarpet (~> 3.6) @@ -1100,4 +1100,4 @@ RUBY VERSION ruby 3.4.8 BUNDLED WITH - 4.0.6 + 4.0.7 diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index b02771879260f6..abd098a14da0bf 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -18,6 +18,8 @@ def show respond_to do |format| format.html do expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in? + + redirect_to short_account_path(@account) if account_id_param.present? && username_param.blank? end format.rss do diff --git a/app/controllers/activitypub/feature_authorizations_controller.rb b/app/controllers/activitypub/feature_authorizations_controller.rb new file mode 100644 index 00000000000000..ef9f458bf78b9d --- /dev/null +++ b/app/controllers/activitypub/feature_authorizations_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ActivityPub::FeatureAuthorizationsController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_collection_item + + def show + expires_in 30.seconds, public: true if public_fetch_mode? + render json: @collection_item, serializer: ActivityPub::FeatureAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_collection_item + @collection_item = @account.collection_items.accepted.find(params[:id]) + + authorize @collection_item.collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index d105f54eb29d22..c84fa6e1f70972 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -65,7 +65,7 @@ def create end # Allow transparently upgrading a domain block - if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(@domain_block.domain.strip) + if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(@domain_block.domain) @domain_block = existing_domain_block @domain_block.assign_attributes(resource_params) end diff --git a/app/controllers/admin/instances/moderation_notes_controller.rb b/app/controllers/admin/instances/moderation_notes_controller.rb index 635c09734933ee..dd6c32bda5799e 100644 --- a/app/controllers/admin/instances/moderation_notes_controller.rb +++ b/app/controllers/admin/instances/moderation_notes_controller.rb @@ -34,8 +34,11 @@ def resource_params end def set_instance - domain = params[:instance_id]&.strip - @instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain)) + @instance = Instance.find_or_initialize_by(domain: normalized_domain) + end + + def normalized_domain + TagManager.instance.normalize_domain(params[:instance_id]) end def set_instance_note diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 6ab4acab99cf58..033d250a2e07c3 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -55,8 +55,11 @@ def stop_delivery private def set_instance - domain = params[:id]&.strip - @instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain)) + @instance = Instance.find_or_initialize_by(domain: normalized_domain) + end + + def normalized_domain + TagManager.instance.normalize_domain(params[:id]) end def set_instances diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb index 61a746d42c055f..faf2bebad16136 100644 --- a/app/controllers/admin/reports/actions_controller.rb +++ b/app/controllers/admin/reports/actions_controller.rb @@ -13,7 +13,7 @@ def create case action_from_button when 'delete', 'mark_as_sensitive', 'force_cw' - Admin::StatusBatchAction.new(status_batch_action_params).save! + Admin::ModerationAction.new(moderation_action_params).save! when 'silence', 'suspend' Admin::AccountAction.new(account_action_params).save! else @@ -25,9 +25,8 @@ def create private - def status_batch_action_params + def moderation_action_params shared_params - .merge(status_ids: @report.status_ids) end def account_action_params diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 956950fe0d3f95..84a1680f9c263d 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -144,8 +144,6 @@ def action_from_button 'report' elsif params[:remove_from_report] 'remove_from_report' - elsif params[:delete] - 'delete' end end end diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb index d9c82327022194..27b7503e9f0784 100644 --- a/app/controllers/api/v1/peers/search_controller.rb +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -47,10 +47,6 @@ def set_domains end def normalized_domain - TagManager.instance.normalize_domain(query_value) - end - - def query_value - params[:q].strip + TagManager.instance.normalize_domain(params[:q]) end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index d8aa66e6ca57ee..5f80383dd376f1 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -144,6 +144,8 @@ def destroy @status = Status.where(account: current_account).find(params[:id]) authorize @status, :destroy? + # JSON is generated before `discard_with_reblogs` in order to have the proper URL + # for media attachments, as it would otherwise redirect to the media proxy json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true @status.discard_with_reblogs diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb index 67a4d8ef492904..36822d831b16d5 100644 --- a/app/controllers/api/v1/tags_controller.rb +++ b/app/controllers/api/v1/tags_controller.rb @@ -39,6 +39,6 @@ def unfeature def set_or_create_tag return not_found unless Tag::HASHTAG_NAME_RE.match?(params[:id]) - @tag = Tag.find_normalized(params[:id]) || Tag.new(name: Tag.normalize(params[:id]), display_name: params[:id]) + @tag = Tag.find_normalized(params[:id]) || Tag.new(name: params[:id], display_name: params[:id]) end end diff --git a/app/controllers/api/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb index 5c78de14e9592b..2c46cc4f9fcda0 100644 --- a/app/controllers/api/v1_alpha/collection_items_controller.rb +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -11,7 +11,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController before_action :set_collection before_action :set_account, only: [:create] - before_action :set_collection_item, only: [:destroy] + before_action :set_collection_item, only: [:destroy, :revoke] after_action :verify_authorized @@ -32,6 +32,14 @@ def destroy head 200 end + def revoke + authorize @collection_item, :revoke? + + RevokeCollectionItemService.new.call(@collection_item) + + head 200 + end + private def set_collection diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 805a8be450093e..d9854042242010 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -26,6 +26,8 @@ def show respond_to do |format| format.html do expires_in 10.seconds, public: true if current_account.nil? + + redirect_to short_account_status_path(@account, @status) if account_id_param.present? && username_param.blank? end format.json do diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 1fad9ceee5c1dd..0ee9c0d66b4ad4 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -19,7 +19,7 @@ def log_target(log) link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance') - when 'Status' + when 'Status', 'Collection' link_to log.human_identifier, log.permalink when 'AccountWarning' link_to log.human_identifier, disputes_strike_path(log.target_id) diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index f387a48309c0d1..f226f231659e88 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -20,4 +20,12 @@ def omniauth_only? def ip_blocked?(remote_ip) IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? end + + def terms_agreement_label + if TermsOfService.live.exists? + t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path) + else + t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path) + end + end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fd631ce92ecd30..44113f3d475d9e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -23,6 +23,16 @@ def featured_tags_hint(recently_used_tags) ) end + def author_attribution_name(account) + return if account.nil? + + link_to(root_url, class: 'story__details__shared__author-link') do + safe_join( + [image_tag(account.avatar.url, class: 'account__avatar', size: 16, alt: ''), tag.bdi(display_name(account))] + ) + end + end + def session_device_icon(session) device = session.detection.device diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index b2a1473dfbc46c..f891410a3cdc48 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -182,15 +182,25 @@ function loaded() { ({ target }) => { if (!(target instanceof HTMLInputElement)) return; - if (target.value && target.value.length > 0) { + const checkedUsername = target.value; + if (checkedUsername && checkedUsername.length > 0) { axios - .get('/api/v1/accounts/lookup', { params: { acct: target.value } }) + .get('/api/v1/accounts/lookup', { + params: { acct: checkedUsername }, + }) .then(() => { - target.setCustomValidity(formatMessage(messages.usernameTaken)); + // Only update the validity if the result is for the currently-typed username + if (checkedUsername === target.value) { + target.setCustomValidity(formatMessage(messages.usernameTaken)); + } + return true; }) .catch(() => { - target.setCustomValidity(''); + // Only update the validity if the result is for the currently-typed username + if (checkedUsername === target.value) { + target.setCustomValidity(''); + } }); } else { target.setCustomValidity(''); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 2af29c783e08b8..39617d82fe04f2 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -179,3 +179,10 @@ export async function apiRequestDelete< >(url: ApiUrl, params?: RequestParamsOrData) { return apiRequest('DELETE', url, { params }); } + +export async function apiRequestPatch( + url: ApiUrl, + data?: RequestParamsOrData, +) { + return apiRequest('PATCH', url, { data }); +} diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index 9c35d619a4cb9f..da4b0e94f8637c 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -1,4 +1,9 @@ -import { apiRequestPost, apiRequestGet, apiRequestDelete } from 'mastodon/api'; +import { + apiRequestPost, + apiRequestGet, + apiRequestDelete, + apiRequestPatch, +} from 'mastodon/api'; import type { ApiAccountJSON, ApiFamiliarFollowersJSON, @@ -9,6 +14,11 @@ import type { ApiHashtagJSON, } from 'mastodon/api_types/tags'; +import type { + ApiProfileJSON, + ApiProfileUpdateParams, +} from '../api_types/profile'; + export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { comment: value, @@ -54,3 +64,8 @@ export const apiGetFamiliarFollowers = (id: string) => apiRequestGet('v1/accounts/familiar_followers', { id, }); + +export const apiGetProfile = () => apiRequestGet('v1/profile'); + +export const apiPatchProfile = (params: ApiProfileUpdateParams) => + apiRequestPatch('v1/profile', params); diff --git a/app/javascript/mastodon/api_types/profile.ts b/app/javascript/mastodon/api_types/profile.ts new file mode 100644 index 00000000000000..9814bddde96006 --- /dev/null +++ b/app/javascript/mastodon/api_types/profile.ts @@ -0,0 +1,44 @@ +import type { ApiAccountFieldJSON } from './accounts'; +import type { ApiFeaturedTagJSON } from './tags'; + +export interface ApiProfileJSON { + id: string; + display_name: string; + note: string; + fields: ApiAccountFieldJSON[]; + avatar: string; + avatar_static: string; + avatar_description: string; + header: string; + header_static: string; + header_description: string; + locked: boolean; + bot: boolean; + hide_collections: boolean; + discoverable: boolean; + indexable: boolean; + show_media: boolean; + show_media_replies: boolean; + show_featured: boolean; + attribution_domains: string[]; + featured_tags: ApiFeaturedTagJSON[]; +} + +export type ApiProfileUpdateParams = Partial< + Pick< + ApiProfileJSON, + | 'display_name' + | 'note' + | 'locked' + | 'bot' + | 'hide_collections' + | 'discoverable' + | 'indexable' + | 'show_media' + | 'show_media_replies' + | 'show_featured' + > +> & { + attribution_domains?: string[]; + fields_attributes?: Pick[]; +}; diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts index 01d7f9e4b616da..52093689bc3470 100644 --- a/app/javascript/mastodon/api_types/tags.ts +++ b/app/javascript/mastodon/api_types/tags.ts @@ -17,16 +17,6 @@ export interface ApiHashtagJSON extends ApiHashtagBase { } export interface ApiFeaturedTagJSON extends ApiHashtagBase { - statuses_count: number; + statuses_count: string; last_status_at: string | null; } - -export function hashtagToFeaturedTag(tag: ApiHashtagJSON): ApiFeaturedTagJSON { - return { - id: tag.id, - name: tag.name, - url: tag.url, - statuses_count: 0, - last_status_at: null, - }; -} diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap index 94d5402b20404d..de4ccd659429dc 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap @@ -26,6 +26,7 @@ exports[` renders a overlay avatar 1`] = ` > alice @@ -44,6 +45,7 @@ exports[` renders a overlay avatar 1`] = ` > eve@blackhat.lair diff --git a/app/javascript/mastodon/components/account/account.stories.tsx b/app/javascript/mastodon/components/account/account.stories.tsx index 050ed6e9005967..0b1e9e29b3c826 100644 --- a/app/javascript/mastodon/components/account/account.stories.tsx +++ b/app/javascript/mastodon/components/account/account.stories.tsx @@ -50,6 +50,10 @@ const meta = { type: 'boolean', description: 'Whether to display the account menu or not', }, + withBorder: { + type: 'boolean', + description: 'Whether to display the bottom border or not', + }, }, args: { name: 'Test User', @@ -60,6 +64,7 @@ const meta = { defaultAction: 'mute', withBio: false, withMenu: true, + withBorder: true, }, parameters: { state: { @@ -103,6 +108,12 @@ export const NoMenu: Story = { }, }; +export const NoBorder: Story = { + args: { + withBorder: false, + }, +}; + export const Blocked: Story = { args: { defaultAction: 'block', diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index f5c7ffc34ac89b..d454f201fcf9b5 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -77,6 +77,7 @@ interface AccountProps { hideButtons?: boolean; childrenA?: ReactNode; withMenu?: boolean; + withBorder?: boolean; extraAccountInfo?: React.ReactNode; children?: React.ReactNode; } @@ -91,6 +92,7 @@ export const Account: React.FC = ({ hideButtons, childrenA, withMenu = true, + withBorder = true, extraAccountInfo, children, }) => { @@ -300,6 +302,7 @@ export const Account: React.FC = ({
= ({ description, }) => { - const accessibilityId = useId(); - const anchorRef = useRef(null); + const intl = useIntl(); + const uniqueId = useId(); + const popoverId = `${uniqueId}-popover`; + const titleId = `${uniqueId}-title`; + const buttonRef = useRef(null); + const popoverRef = useRef(null); const [open, setOpen] = useState(false); const handleClick = useCallback(() => { setOpen((v) => !v); + setTimeout(() => { + popoverRef.current?.focus(); + }, 0); }, [setOpen]); const handleClose = useCallback(() => { setOpen(false); + buttonRef.current?.focus(); }, [setOpen]); const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose); @@ -34,11 +47,12 @@ export const AltTextBadge: React.FC<{ description: string }> = ({ <> @@ -47,7 +61,7 @@ export const AltTextBadge: React.FC<{ description: string }> = ({ rootClose onHide={handleClose} show={open} - target={anchorRef} + target={buttonRef} placement='top-end' flip offset={offset} @@ -57,17 +71,34 @@ export const AltTextBadge: React.FC<{ description: string }> = ({
-

+

+ + +

{description}

diff --git a/app/javascript/mastodon/components/alt_text_badge/styles.module.scss b/app/javascript/mastodon/components/alt_text_badge/styles.module.scss new file mode 100644 index 00000000000000..1b7d5ec788a7c4 --- /dev/null +++ b/app/javascript/mastodon/components/alt_text_badge/styles.module.scss @@ -0,0 +1,17 @@ +.closeButton { + position: absolute; + top: 5px; + inset-inline-end: 2px; + padding: 10px; + + --default-icon-color: var(--color-text-on-media); + --default-bg-color: transparent; + --hover-icon-color: var(--color-text-on-media); + --hover-bg-color: rgb(from var(--color-text-on-media) r g b / 10%); + --focus-outline-color: var(--color-text-on-media); + + svg { + width: 20px; + height: 20px; + } +} diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx index 6e1c5dbfd4ca98..b086ef42259dcc 100644 --- a/app/javascript/mastodon/components/avatar.tsx +++ b/app/javascript/mastodon/components/avatar.tsx @@ -7,6 +7,8 @@ import { useHovering } from 'mastodon/hooks/useHovering'; import { autoPlayGif } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; +import { useAccount } from '../hooks/useAccount'; + interface Props { account: | Pick @@ -91,3 +93,10 @@ export const Avatar: React.FC = ({ return avatar; }; + +export const AvatarById: React.FC< + { accountId: string } & Omit +> = ({ accountId, ...otherProps }) => { + const account = useAccount(accountId); + return ; +}; diff --git a/app/javascript/mastodon/components/avatar_overlay.tsx b/app/javascript/mastodon/components/avatar_overlay.tsx index 0bd33fea6937b6..e7fc1252c1ba4a 100644 --- a/app/javascript/mastodon/components/avatar_overlay.tsx +++ b/app/javascript/mastodon/components/avatar_overlay.tsx @@ -10,6 +10,14 @@ interface Props { overlaySize?: number; } +const handleImgLoadError = (error: { currentTarget: HTMLElement }) => { + // + // When the img tag fails to load the image, set the img tag to display: none. This prevents the + // alt-text from overrunning the containing div. + // + error.currentTarget.style.display = 'none'; +}; + export const AvatarOverlay: React.FC = ({ account, friend, @@ -38,7 +46,13 @@ export const AvatarOverlay: React.FC = ({ className='account__avatar' style={{ width: `${baseSize}px`, height: `${baseSize}px` }} > - {accountSrc && {account?.get('acct')}} + {accountSrc && ( + {account?.get('acct')} + )}
@@ -46,7 +60,13 @@ export const AvatarOverlay: React.FC = ({ className='account__avatar' style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }} > - {friendSrc && {friend?.get('acct')}} + {friendSrc && ( + {friend?.get('acct')} + )}
diff --git a/app/javascript/mastodon/components/character_counter/character_counter.stories.tsx b/app/javascript/mastodon/components/character_counter/character_counter.stories.tsx new file mode 100644 index 00000000000000..a37a74af45e93a --- /dev/null +++ b/app/javascript/mastodon/components/character_counter/character_counter.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CharacterCounter } from './index'; + +const meta = { + component: CharacterCounter, + title: 'Components/CharacterCounter', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Required: Story = { + args: { + currentString: 'Hello, world!', + maxLength: 100, + }, +}; + +export const ExceedingLimit: Story = { + args: { + currentString: 'Hello, world!', + maxLength: 10, + }, +}; + +export const Recommended: Story = { + args: { + currentString: 'Hello, world!', + maxLength: 10, + recommended: true, + }, +}; diff --git a/app/javascript/mastodon/components/character_counter/index.tsx b/app/javascript/mastodon/components/character_counter/index.tsx new file mode 100644 index 00000000000000..dce410a7c1336f --- /dev/null +++ b/app/javascript/mastodon/components/character_counter/index.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import classes from './styles.module.scss'; + +interface CharacterCounterProps { + currentString: string; + maxLength: number; + recommended?: boolean; +} + +const segmenter = new Intl.Segmenter(); + +export const CharacterCounter = polymorphicForwardRef< + 'span', + CharacterCounterProps +>( + ( + { + currentString, + maxLength, + as: Component = 'span', + recommended = false, + ...props + }, + ref, + ) => { + const currentLength = useMemo( + () => [...segmenter.segment(currentString)].length, + [currentString], + ); + return ( + maxLength && !recommended && classes.counterError, + )} + > + {recommended ? ( + + ) : ( + + )} + + ); + }, +); +CharacterCounter.displayName = 'CharCounter'; diff --git a/app/javascript/mastodon/components/character_counter/styles.module.scss b/app/javascript/mastodon/components/character_counter/styles.module.scss new file mode 100644 index 00000000000000..05c9446545ac02 --- /dev/null +++ b/app/javascript/mastodon/components/character_counter/styles.module.scss @@ -0,0 +1,8 @@ +.counter { + margin-top: 4px; + font-size: 13px; +} + +.counterError { + color: var(--color-text-error); +} diff --git a/app/javascript/mastodon/components/column_back_button.tsx b/app/javascript/mastodon/components/column_back_button.tsx index 8012ba7df694e6..bb6939e24c60e7 100644 --- a/app/javascript/mastodon/components/column_back_button.tsx +++ b/app/javascript/mastodon/components/column_back_button.tsx @@ -4,8 +4,11 @@ import { FormattedMessage } from 'react-intl'; import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import { Icon } from 'mastodon/components/icon'; +import { getColumnSkipLinkId } from 'mastodon/features/ui/components/skip_links'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; +import { useColumnIndexContext } from '../features/ui/components/columns_area'; + import { useAppHistory } from './router'; type OnClickCallback = () => void; @@ -28,9 +31,15 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({ onClick, }) => { const handleClick = useHandleClick(onClick); + const columnIndex = useColumnIndexContext(); const component = ( - @@ -221,7 +226,7 @@ export const ColumnHeader: React.FC = ({ !pinned && ((multiColumn && history.location.state?.fromMastodon) || showBackButton) ) { - backButton = ; + backButton = ; } const collapsedContent = [extraContent]; @@ -260,6 +265,7 @@ export const ColumnHeader: React.FC = ({ const hasIcon = icon && iconComponent; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasTitle = (hasIcon || backButton) && title; + const columnIndex = useColumnIndexContext(); const component = (
@@ -272,6 +278,7 @@ export const ColumnHeader: React.FC = ({ onClick={handleTitleClick} className='column-header__title' type='button' + id={getColumnSkipLinkId(columnIndex)} > {!backButton && hasIcon && ( = ({ title, value, className }) => { + className?: string; + 'aria-describedby'?: string; +}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => { const [copied, setCopied] = useState(false); const dispatch = useAppDispatch(); @@ -38,8 +39,9 @@ export const CopyIconButton: React.FC<{ className={classNames(className, copied ? 'copied' : 'copyable')} title={title} onClick={handleClick} - icon='' + icon='copy-icon' iconComponent={ContentCopyIcon} + aria-describedby={ariaDescribedBy} /> ); }; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index bc3eda7a33238a..a9f2b64e337488 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -20,18 +20,7 @@ export interface EmojiHTMLProps { } export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( - ( - { - extraEmojis, - htmlString, - as: asProp = 'div', // Rename for syntax highlighting - className, - onElement, - onAttribute, - ...props - }, - ref, - ) => { + ({ extraEmojis, htmlString, onElement, onAttribute, ...props }, ref) => { const contents = useMemo( () => htmlStringToComponents(htmlString, { @@ -44,12 +33,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( return ( - + {contents} diff --git a/app/javascript/mastodon/components/emoji/picker_button.tsx b/app/javascript/mastodon/components/emoji/picker_button.tsx new file mode 100644 index 00000000000000..6440ad34b55a4a --- /dev/null +++ b/app/javascript/mastodon/components/emoji/picker_button.tsx @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container'; + +export const EmojiPickerButton: FC<{ + onPick: (emoji: string) => void; + disabled?: boolean; +}> = ({ onPick, disabled }) => { + const handlePick = useCallback( + (emoji: unknown) => { + if (disabled) { + return; + } + if (typeof emoji === 'object' && emoji !== null) { + if ('native' in emoji && typeof emoji.native === 'string') { + onPick(emoji.native); + } else if ( + 'shortcode' in emoji && + typeof emoji.shortcode === 'string' + ) { + onPick(`:${emoji.shortcode}:`); + } + } + }, + [disabled, onPick], + ); + return ; +}; diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index a4d89dbcee40b9..000702b4d81d5f 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -138,6 +138,8 @@ export const FollowButton: React.FC<{ : messages.follow; let label; + let disabled = + relationship?.blocked_by || account?.suspended || !!account?.moved; if (!signedIn) { label = intl.formatMessage(followMessage); @@ -147,12 +149,16 @@ export const FollowButton: React.FC<{ label = ; } else if (relationship.muting && withUnmute) { label = intl.formatMessage(messages.unmute); + disabled = false; } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); + disabled = false; } else if (relationship.blocking) { label = intl.formatMessage(messages.unblock); + disabled = false; } else if (relationship.requested) { label = intl.formatMessage(messages.followRequestCancel); + disabled = false; } else if ( relationship.followed_by && !account?.locked && @@ -191,11 +197,7 @@ export const FollowButton: React.FC<{ return (