From 080110472947508f0236278a74550a7dd2346466 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 16 Dec 2025 09:49:48 +0100 Subject: [PATCH 01/16] Fix mentions of domain-blocked users being processed (#37257) --- app/services/process_mentions_service.rb | 2 +- spec/services/process_mentions_service_spec.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 6906f77e1e..c2c33689ea 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -71,7 +71,7 @@ class ProcessMentionsService < BaseService # Make sure we never mention blocked accounts unless @current_mentions.empty? mentioned_domains = @current_mentions.filter_map { |m| m.account.domain }.uniq - blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains)) + blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains).pluck(:domain)) mentioned_account_ids = @current_mentions.map(&:account_id) blocked_account_ids = Set.new(@status.account.block_relationships.where(target_account_id: mentioned_account_ids).pluck(:target_account_id)) diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 3cc83d82f3..61faf3d04a 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -8,9 +8,9 @@ RSpec.describe ProcessMentionsService do let(:account) { Fabricate(:account, username: 'alice') } context 'when mentions contain blocked accounts' do - let(:non_blocked_account) { Fabricate(:account) } - let(:individually_blocked_account) { Fabricate(:account) } - let(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com') } + let!(:non_blocked_account) { Fabricate(:account) } + let!(:individually_blocked_account) { Fabricate(:account) } + let!(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com', protocol: :activitypub) } let(:status) { Fabricate(:status, account: account, text: "Hello @#{non_blocked_account.acct} @#{individually_blocked_account.acct} @#{domain_blocked_account.acct}", visibility: :public) } before do From 71821eb9c1f36fef7a515c2a0c5cf104054b5206 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:45:59 +0100 Subject: [PATCH 02/16] Update dependency tzinfo-data to v1.2025.3 (#37242) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c40b45088d..7664743aa2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -166,7 +166,7 @@ GEM climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.6) connection_pool (2.5.5) cose (1.3.1) cbor (~> 0.5.9) @@ -880,7 +880,7 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.2) + tzinfo-data (1.2025.3) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext From 4c679c698f77daf14949ca29d0000e3df6c13f73 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:46:39 +0000 Subject: [PATCH 03/16] Update dependency vite-tsconfig-paths to v6 (#37247) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 38da4d371e..794b123385 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "vite-plugin-manifest-sri": "^0.2.0", "vite-plugin-pwa": "^1.0.2", "vite-plugin-svgr": "^4.3.0", - "vite-tsconfig-paths": "^5.1.4", + "vite-tsconfig-paths": "^6.0.0", "wicg-inert": "^3.1.2", "workbox-expiration": "^7.3.0", "workbox-routing": "^7.3.0", diff --git a/yarn.lock b/yarn.lock index c5c229eb09..96ace4299b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2884,7 +2884,7 @@ __metadata: vite-plugin-manifest-sri: "npm:^0.2.0" vite-plugin-pwa: "npm:^1.0.2" vite-plugin-svgr: "npm:^4.3.0" - vite-tsconfig-paths: "npm:^5.1.4" + vite-tsconfig-paths: "npm:^6.0.0" vitest: "npm:^4.0.5" wicg-inert: "npm:^3.1.2" workbox-expiration: "npm:^7.3.0" @@ -14068,9 +14068,9 @@ __metadata: languageName: node linkType: hard -"vite-tsconfig-paths@npm:^5.1.4": - version: 5.1.4 - resolution: "vite-tsconfig-paths@npm:5.1.4" +"vite-tsconfig-paths@npm:^6.0.0": + version: 6.0.1 + resolution: "vite-tsconfig-paths@npm:6.0.1" dependencies: debug: "npm:^4.1.1" globrex: "npm:^0.1.2" @@ -14080,7 +14080,7 @@ __metadata: peerDependenciesMeta: vite: optional: true - checksum: 10c0/6228f23155ea25d92b1e1702284cf8dc52ad3c683c5ca691edd5a4c82d2913e7326d00708cef1cbfde9bb226261df0e0a12e03ef1d43b6a92d8f02b483ef37e3 + checksum: 10c0/c0702f1d2b9d2e3e6ebb44d8e9c27b17b1102e86946ab54b6bbd290419b134e84df4e451b55db973bc97d9de5689df6f67e479633df20244aa0c62ffd0b16e43 languageName: node linkType: hard From 550a6d4765e5af144052b9fb6cdc5d3a4e958a87 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 16 Dec 2025 10:47:18 +0100 Subject: [PATCH 04/16] Add wrapstodon to initial state and show wrapstodon sidebar item on load (#37261) --- .../api/v1/annual_reports_controller.rb | 19 +++++-------------- app/javascript/mastodon/initial_state.ts | 7 +++++++ .../mastodon/reducers/slices/annual_report.ts | 11 ++++++++--- app/lib/annual_report.rb | 19 +++++++++++++++++++ app/serializers/initial_state_serializer.rb | 11 +++++++++++ 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb index 724c7658d7..71a97e1d9a 100644 --- a/app/controllers/api/v1/annual_reports_controller.rb +++ b/app/controllers/api/v1/annual_reports_controller.rb @@ -57,27 +57,18 @@ class Api::V1::AnnualReportsController < Api::BaseController render_empty end - def refresh_key - "wrapstodon:#{current_account.id}:#{year}" - end - private def report_state - return 'available' if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year) - - async_refresh = AsyncRefresh.new(refresh_key) - - if async_refresh.running? + AnnualReport.new(current_account, year).state do |async_refresh| add_async_refresh_header(async_refresh, retry_seconds: 2) - 'generating' - elsif AnnualReport.current_campaign == year && AnnualReport.new(current_account, year).eligible? - 'eligible' - else - 'ineligible' end end + def refresh_key + "wrapstodon:#{current_account.id}:#{year}" + end + def year params[:id]&.to_i end diff --git a/app/javascript/mastodon/initial_state.ts b/app/javascript/mastodon/initial_state.ts index 3bfd48a76b..358c307c30 100644 --- a/app/javascript/mastodon/initial_state.ts +++ b/app/javascript/mastodon/initial_state.ts @@ -2,6 +2,11 @@ import type { ApiAccountJSON } from './api_types/accounts'; type InitialStateLanguage = [code: string, name: string, localName: string]; +interface InitialWrapstodonState { + year: number; + state: 'available' | 'generating' | 'eligible' | 'ineligible'; +} + interface InitialStateMeta { access_token: string; advanced_layout?: boolean; @@ -47,6 +52,7 @@ interface InitialStateMeta { status_page_url: string; terms_of_service_enabled: boolean; emoji_style?: string; + wrapstodon?: InitialWrapstodonState | null; } interface Role { @@ -128,6 +134,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending; export const statusPageUrl = getMeta('status_page_url'); export const sso_redirect = getMeta('sso_redirect'); export const termsOfServiceEnabled = getMeta('terms_of_service_enabled'); +export const wrapstodon = getMeta('wrapstodon'); const displayNames = // Intl.DisplayNames can be undefined in old browsers diff --git a/app/javascript/mastodon/reducers/slices/annual_report.ts b/app/javascript/mastodon/reducers/slices/annual_report.ts index a687f558e1..5e7f44798a 100644 --- a/app/javascript/mastodon/reducers/slices/annual_report.ts +++ b/app/javascript/mastodon/reducers/slices/annual_report.ts @@ -11,6 +11,7 @@ import { apiGetAnnualReportState, apiRequestGenerateAnnualReport, } from '@/mastodon/api/annual_report'; +import { wrapstodon } from '@/mastodon/initial_state'; import type { AnnualReport } from '@/mastodon/models/annual_report'; import { @@ -20,13 +21,17 @@ import { } from '../../store/typed_functions'; interface AnnualReportState { + year?: number; state?: ApiAnnualReportState; report?: AnnualReport; } const annualReportSlice = createSlice({ name: 'annualReport', - initialState: {} as AnnualReportState, + initialState: { + year: wrapstodon?.year, + state: wrapstodon?.state, + } as AnnualReportState, reducers: { setReport(state, action: PayloadAction) { state.report = action.payload; @@ -53,8 +58,8 @@ export const annualReport = annualReportSlice.reducer; export const { setReport } = annualReportSlice.actions; export const selectWrapstodonYear = createAppSelector( - [(state) => state.server.getIn(['server', 'wrapstodon'])], - (year: unknown) => (typeof year === 'number' && year > 2000 ? year : null), + [(state) => state.annualReport.year], + (year: number | null | undefined) => year ?? null, ); // This kicks everything off, and is called after fetching the server info. diff --git a/app/lib/annual_report.rb b/app/lib/annual_report.rb index a9c9135ed4..f487cb883f 100644 --- a/app/lib/annual_report.rb +++ b/app/lib/annual_report.rb @@ -34,6 +34,25 @@ class AnnualReport end end + def state + return 'available' if GeneratedAnnualReport.exists?(account_id: @account.id, year: @year) + + async_refresh = AsyncRefresh.new(refresh_key) + + if async_refresh.running? + yield async_refresh if block_given? + 'generating' + elsif AnnualReport.current_campaign == @year && eligible? + 'eligible' + else + 'ineligible' + end + end + + def refresh_key + "wrapstodon:#{@account.id}:#{@year}" + end + def generate return if GeneratedAnnualReport.exists?(account: @account, year: @year) diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 5f8921e246..fe2a857d50 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -32,6 +32,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:use_pending_items] = object_account_user.setting_use_pending_items store[:show_trends] = Setting.trends && object_account_user.setting_trends store[:emoji_style] = object_account_user.settings['web.emoji_style'] + store[:wrapstodon] = wrapstodon else store[:auto_play_gif] = Setting.auto_play_gif store[:display_media] = Setting.display_media @@ -94,6 +95,16 @@ class InitialStateSerializer < ActiveModel::Serializer private + def wrapstodon + current_campaign = AnnualReport.current_campaign + return if current_campaign.blank? + + { + year: current_campaign, + state: AnnualReport.new(object.current_account, current_campaign).state, + } + end + def default_meta_store { access_token: object.token, From c8f608839b958aec51f94354c8a8a0223250ebd3 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 16 Dec 2025 05:13:43 -0500 Subject: [PATCH 05/16] Use bundler version 4.0.1 (#37191) --- Gemfile | 2 +- Gemfile.lock | 68 ++++++++++++++++++++++++++-------------------------- package.json | 2 +- yarn.lock | 20 ++++++++-------- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/Gemfile b/Gemfile index 60dc80a9bc..0a7c00b11e 100644 --- a/Gemfile +++ b/Gemfile @@ -138,7 +138,7 @@ group :test do # Browser integration testing gem 'capybara', '~> 3.39' gem 'capybara-playwright-driver' - gem 'playwright-ruby-client', '1.56.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package + gem 'playwright-ruby-client', '1.57.0', 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 7664743aa2..0692e07135 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,7 +96,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1190.0) + aws-partitions (1.1194.0) aws-sdk-core (3.239.2) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -108,7 +108,7 @@ GEM aws-sdk-kms (1.118.0) aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.206.0) + aws-sdk-s3 (1.207.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -182,7 +182,7 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) - date (3.5.0) + date (3.5.1) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -208,7 +208,7 @@ GEM domain_name (0.6.20240107) doorkeeper (5.8.2) railties (>= 5) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) dry-cli (1.3.0) elasticsearch (7.17.11) @@ -227,11 +227,11 @@ GEM mail (~> 2.7) email_validator (2.2.4) activemodel - erb (5.1.3) + erb (6.0.1) erubi (1.13.1) et-orbi (1.4.0) tzinfo - excon (1.3.0) + excon (1.3.2) logger fabrication (3.0.0) faker (3.5.3) @@ -244,8 +244,8 @@ GEM faraday (>= 1, < 3) faraday-httpclient (2.0.2) httpclient (>= 2.2) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) fast_blank (1.0.1) fastimage (2.4.0) ffi (1.17.2) @@ -269,20 +269,20 @@ GEM fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) - formatador (1.2.1) + formatador (1.2.3) reline forwardable (1.3.3) - fugit (1.12.0) + fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.32.1) + google-protobuf (4.33.2) bigdecimal rake (>= 13) googleapis-common-protos-types (1.22.0) google-protobuf (~> 4.26) - haml (6.4.0) + haml (7.1.0) temple (>= 0.8.2) thor tilt @@ -291,7 +291,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.67.0) + haml_lint (0.68.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -340,7 +340,7 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.1) + io-console (0.8.2) irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) @@ -350,7 +350,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.16.0) + json (2.18.0) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -447,13 +447,13 @@ GEM mime-types-data (3.2025.0924) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.2) + minitest (5.27.0) msgpack (1.8.0) - multi_json (1.17.0) + multi_json (1.18.0) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.12) + net-imap (0.6.0) date net-protocol net-ldap (0.20.0) @@ -465,7 +465,7 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) + nio4r (2.7.5) nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) @@ -594,7 +594,7 @@ GEM pg (1.6.2) pghero (3.7.0) activerecord (>= 7.1) - playwright-ruby-client (1.56.0) + playwright-ruby-client (1.57.0) concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) pp (0.6.3) @@ -615,7 +615,7 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.3.0) date stringio public_suffix (7.0.0) @@ -631,7 +631,7 @@ GEM rack-cors (3.0.0) logger rack (>= 3.0.14) - rack-oauth2 (2.2.1) + rack-oauth2 (2.3.0) activesupport attr_required faraday (~> 2.0) @@ -649,7 +649,7 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) rails (8.0.3) actioncable (= 8.0.3) @@ -695,7 +695,7 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.15.1) + rdoc (6.17.0) erb psych (>= 4.0.0) tsort @@ -721,18 +721,18 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.1) - rspec (3.13.1) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (3.0.0) rspec-core (~> 3.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (8.0.2) @@ -820,7 +820,7 @@ GEM sidekiq-scheduler (6.0.1) rufus-scheduler (~> 3.2) sidekiq (>= 7.3, < 9) - sidekiq-unique-jobs (8.0.11) + sidekiq-unique-jobs (8.0.12) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 7.0.0, < 9.0.0) thor (>= 1.0, < 3.0) @@ -842,7 +842,7 @@ GEM stoplight (5.7.0) concurrent-ruby zeitwerk - stringio (3.1.8) + stringio (3.1.9) strong_migrations (2.5.1) activerecord (>= 7.1) swd (2.0.3) @@ -859,7 +859,7 @@ GEM test-prof (1.5.0) thor (1.4.0) tilt (2.6.1) - timeout (0.4.3) + timeout (0.5.0) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) @@ -920,7 +920,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) + webrick (1.9.2) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -1033,7 +1033,7 @@ DEPENDENCIES parslet pg (~> 1.5) pghero - playwright-ruby-client (= 1.56.0) + playwright-ruby-client (= 1.57.0) premailer-rails prometheus_exporter (~> 2.2) propshaft @@ -1090,7 +1090,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.1p0 BUNDLED WITH - 2.7.2 + 4.0.1 diff --git a/package.json b/package.json index 794b123385..8a1ecc7737 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,7 @@ "lint-staged": "^16.2.6", "msw": "^2.12.1", "msw-storybook-addon": "^2.0.6", - "playwright": "^1.56.1", + "playwright": "^1.57.0", "prettier": "^3.3.3", "react-test-renderer": "^18.2.0", "storybook": "^10.0.5", diff --git a/yarn.lock b/yarn.lock index 96ace4299b..cac2a0c716 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2838,7 +2838,7 @@ __metadata: msw: "npm:^2.12.1" msw-storybook-addon: "npm:^2.0.6" path-complete-extname: "npm:^1.0.0" - playwright: "npm:^1.56.1" + playwright: "npm:^1.57.0" postcss-preset-env: "npm:^10.1.5" prettier: "npm:^3.3.3" prop-types: "npm:^15.8.1" @@ -10542,27 +10542,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.56.1": - version: 1.56.1 - resolution: "playwright-core@npm:1.56.1" +"playwright-core@npm:1.57.0": + version: 1.57.0 + resolution: "playwright-core@npm:1.57.0" bin: playwright-core: cli.js - checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7 + checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9 languageName: node linkType: hard -"playwright@npm:^1.56.1": - version: 1.56.1 - resolution: "playwright@npm:1.56.1" +"playwright@npm:^1.57.0": + version: 1.57.0 + resolution: "playwright@npm:1.57.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.56.1" + playwright-core: "npm:1.57.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4 + checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899 languageName: node linkType: hard From 7230c2059f1599809f812feeaad94c77aebf113e Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 16 Dec 2025 05:24:27 -0500 Subject: [PATCH 06/16] Add coverage for "domain variants" consumers (#35995) --- app/models/domain_allow.rb | 2 +- spec/models/domain_allow_spec.rb | 23 +++++++++++++ spec/models/domain_block_spec.rb | 11 ++++++ spec/models/email_domain_block_spec.rb | 42 ++++++++++++++++++++--- spec/models/preview_card_provider_spec.rb | 32 +++++++++++++++++ 5 files changed, 105 insertions(+), 5 deletions(-) diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb index 8eab3164e5..783269bf25 100644 --- a/app/models/domain_allow.rb +++ b/app/models/domain_allow.rb @@ -33,7 +33,7 @@ class DomainAllow < ApplicationRecord def rule_for(domain) return if domain.blank? - uri = Addressable::URI.new.tap { |u| u.host = domain.delete('/') } + uri = Addressable::URI.new.tap { |u| u.host = domain.strip.delete('/') } find_by(domain: uri.normalized_host) end diff --git a/spec/models/domain_allow_spec.rb b/spec/models/domain_allow_spec.rb index 0c69aaff8d..ac0c113d3a 100644 --- a/spec/models/domain_allow_spec.rb +++ b/spec/models/domain_allow_spec.rb @@ -27,4 +27,27 @@ RSpec.describe DomainAllow do it { is_expected.to contain_exactly(allowed_domain.domain, other_allowed_domain.domain) } end end + + describe '.rule_for' do + subject { described_class.rule_for(domain) } + + let(:domain) { 'host.example' } + + context 'with no records' do + it { is_expected.to be_nil } + end + + context 'with matching record' do + let!(:domain_allow) { Fabricate :domain_allow, domain: } + + it { is_expected.to eq(domain_allow) } + end + + context 'when called with non normalized string' do + let!(:domain_allow) { Fabricate :domain_allow, domain: } + let(:domain) { ' HOST.example/' } + + it { is_expected.to eq(domain_allow) } + end + end end diff --git a/spec/models/domain_block_spec.rb b/spec/models/domain_block_spec.rb index 14f904ea7f..e7f463c8f5 100644 --- a/spec/models/domain_block_spec.rb +++ b/spec/models/domain_block_spec.rb @@ -35,6 +35,17 @@ RSpec.describe DomainBlock do expect(described_class.rule_for('example.com')).to eq block end + it 'returns most specific rule matching a blocked domain' do + _block = Fabricate(:domain_block, domain: 'example.com') + blog_block = Fabricate(:domain_block, domain: 'blog.example.com') + expect(described_class.rule_for('host.blog.example.com')).to eq blog_block + end + + it 'returns rule matching a blocked domain when string needs normalization' do + block = Fabricate(:domain_block, domain: 'example.com') + expect(described_class.rule_for(' example.com/')).to eq block + end + it 'returns a rule matching a subdomain of a blocked domain' do block = Fabricate(:domain_block, domain: 'example.com') expect(described_class.rule_for('sub.example.com')).to eq block diff --git a/spec/models/email_domain_block_spec.rb b/spec/models/email_domain_block_spec.rb index 5874c5e53c..c3662b2d6c 100644 --- a/spec/models/email_domain_block_spec.rb +++ b/spec/models/email_domain_block_spec.rb @@ -4,6 +4,8 @@ require 'rails_helper' RSpec.describe EmailDomainBlock do describe 'block?' do + subject { described_class.block?(input) } + let(:input) { nil } context 'when given an e-mail address' do @@ -14,12 +16,12 @@ RSpec.describe EmailDomainBlock do it 'returns true if the domain is blocked' do Fabricate(:email_domain_block, domain: 'example.com') - expect(described_class.block?(input)).to be true + expect(subject).to be true end it 'returns false if the domain is not blocked' do Fabricate(:email_domain_block, domain: 'other-example.com') - expect(described_class.block?(input)).to be false + expect(subject).to be false end end @@ -28,7 +30,7 @@ RSpec.describe EmailDomainBlock do it 'returns true if it is a subdomain of a blocked domain' do Fabricate(:email_domain_block, domain: 'example.com') - expect(described_class.block?(input)).to be true + expect(subject).to be true end end end @@ -38,8 +40,40 @@ RSpec.describe EmailDomainBlock do it 'returns true if the domain is blocked' do Fabricate(:email_domain_block, domain: 'mail.foo.com') - expect(described_class.block?(input)).to be true + expect(subject).to be true end end + + context 'when given nil' do + it { is_expected.to be false } + end + + context 'when given empty string' do + let(:input) { '' } + + it { is_expected.to be true } + end + end + + describe '.requires_approval?' do + subject { described_class.requires_approval?(input) } + + let(:input) { nil } + + context 'with a matching block requiring approval' do + before { Fabricate :email_domain_block, domain: input, allow_with_approval: true } + + let(:input) { 'host.example' } + + it { is_expected.to be true } + end + + context 'with a matching block not requiring approval' do + before { Fabricate :email_domain_block, domain: input, allow_with_approval: false } + + let(:input) { 'host.example' } + + it { is_expected.to be false } + end end end diff --git a/spec/models/preview_card_provider_spec.rb b/spec/models/preview_card_provider_spec.rb index 561c93d0b2..cd3283faa3 100644 --- a/spec/models/preview_card_provider_spec.rb +++ b/spec/models/preview_card_provider_spec.rb @@ -25,4 +25,36 @@ RSpec.describe PreviewCardProvider do end end end + + describe '.matching_domain' do + subject { described_class.matching_domain(domain) } + + let(:domain) { 'host.example' } + + context 'without matching domains' do + it { is_expected.to be_nil } + end + + context 'with exact matching domain' do + let!(:preview_card_provider) { Fabricate :preview_card_provider, domain: 'host.example' } + + it { is_expected.to eq(preview_card_provider) } + end + + context 'with matching domain segment' do + let!(:preview_card_provider) { Fabricate :preview_card_provider, domain: 'host.example' } + let(:domain) { 'www.blog.host.example' } + + it { is_expected.to eq(preview_card_provider) } + end + + context 'with multiple matching records' do + let!(:preview_card_provider_more) { Fabricate :preview_card_provider, domain: 'blog.host.example' } + let(:domain) { 'www.blog.host.example' } + + before { Fabricate :preview_card_provider, domain: 'host.example' } + + it { is_expected.to eq(preview_card_provider_more) } + end + end end From 8c2845906c1f8f60708dad452b056bc7696c1224 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 16 Dec 2025 12:27:18 +0100 Subject: [PATCH 07/16] Improve Redux Storybook (#37227) --- .storybook/preview.tsx | 25 +++++++++++++-- ...ybook-addon-vitest.d.ts => storybook.d.ts} | 13 ++++++++ .../components/account/account.stories.tsx | 32 +++++++++++++------ .../components/emoji/emoji.stories.tsx | 14 ++++---- 4 files changed, 63 insertions(+), 21 deletions(-) rename .storybook/{storybook-addon-vitest.d.ts => storybook.d.ts} (54%) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 32d4bc1867..abbd193c68 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -55,12 +55,31 @@ const preview: Preview = { locale: 'en', }, decorators: [ - (Story, { parameters, globals, args }) => { + (Story, { parameters, globals, args, argTypes }) => { // Get the locale from the global toolbar // and merge it with any parameters or args state. const { locale } = globals as { locale: string }; const { state = {} } = parameters; - const { state: argsState = {} } = args; + + const argsState: Record = {}; + for (const [key, value] of Object.entries(args)) { + const argType = argTypes[key]; + if (argType?.reduxPath) { + const reduxPath = Array.isArray(argType.reduxPath) + ? argType.reduxPath.map((p) => p.toString()) + : argType.reduxPath.split('.'); + + reduxPath.reduce((acc, key, i) => { + if (acc[key] === undefined) { + acc[key] = {}; + } + if (i === reduxPath.length - 1) { + acc[key] = value; + } + return acc[key] as Record; + }, argsState); + } + } const reducer = reducerWithInitialState( { @@ -69,7 +88,7 @@ const preview: Preview = { }, }, state as Record, - argsState as Record, + argsState, ); const store = configureStore({ diff --git a/.storybook/storybook-addon-vitest.d.ts b/.storybook/storybook.d.ts similarity index 54% rename from .storybook/storybook-addon-vitest.d.ts rename to .storybook/storybook.d.ts index 86852faca9..47624d1e9c 100644 --- a/.storybook/storybook-addon-vitest.d.ts +++ b/.storybook/storybook.d.ts @@ -1,7 +1,20 @@ // The addon package.json incorrectly exports types, so we need to override them here. + +import type { RootState } from '@/mastodon/store'; + // See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76 declare module '@storybook/addon-vitest/vitest-plugin' { export * from '@storybook/addon-vitest/dist/vitest-plugin/index'; } +type RootPathKeys = keyof RootState; + +declare module 'storybook/internal/csf' { + export interface InputType { + reduxPath?: + | `${RootPathKeys}.${string}` + | [RootPathKeys, ...(string | number)[]]; + } +} + export {}; diff --git a/app/javascript/mastodon/components/account/account.stories.tsx b/app/javascript/mastodon/components/account/account.stories.tsx index 3a3a255b7f..050ed6e900 100644 --- a/app/javascript/mastodon/components/account/account.stories.tsx +++ b/app/javascript/mastodon/components/account/account.stories.tsx @@ -1,16 +1,28 @@ +import type { ComponentProps } from 'react'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import { accountFactoryState, relationshipsFactory } from '@/testing/factories'; import { Account } from './index'; +type Props = Omit, 'id'> & { + name: string; + username: string; +}; + const meta = { title: 'Components/Account', - component: Account, argTypes: { - id: { + name: { type: 'string', - description: 'ID of the account to display', + description: 'The display name of the account', + reduxPath: 'accounts.1.display_name_html', + }, + username: { + type: 'string', + description: 'The username of the account', + reduxPath: 'accounts.1.acct', }, size: { type: 'number', @@ -40,7 +52,8 @@ const meta = { }, }, args: { - id: '1', + name: 'Test User', + username: 'testuser', size: 46, hidden: false, minimal: false, @@ -55,17 +68,16 @@ const meta = { }, }, }, -} satisfies Meta; + render(args) { + return ; + }, +} satisfies Meta; export default meta; type Story = StoryObj; -export const Primary: Story = { - args: { - id: '1', - }, -}; +export const Primary: Story = {}; export const Hidden: Story = { args: { diff --git a/app/javascript/mastodon/components/emoji/emoji.stories.tsx b/app/javascript/mastodon/components/emoji/emoji.stories.tsx index d390387a03..fcc81db6c3 100644 --- a/app/javascript/mastodon/components/emoji/emoji.stories.tsx +++ b/app/javascript/mastodon/components/emoji/emoji.stories.tsx @@ -4,20 +4,22 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Emoji } from './index'; -type EmojiProps = ComponentProps & { state: string }; +type EmojiProps = ComponentProps & { + style: 'auto' | 'native' | 'twemoji'; +}; const meta = { title: 'Components/Emoji', component: Emoji, args: { code: '🖤', - state: 'auto', + style: 'auto', }, argTypes: { code: { name: 'Emoji', }, - state: { + style: { control: { type: 'select', labels: { @@ -28,11 +30,7 @@ const meta = { }, options: ['auto', 'native', 'twemoji'], name: 'Emoji Style', - mapping: { - auto: { meta: { emoji_style: 'auto' } }, - native: { meta: { emoji_style: 'native' } }, - twemoji: { meta: { emoji_style: 'twemoji' } }, - }, + reduxPath: 'meta.emoji_style', }, }, render(args) { From 9e97ad04d8e771dd8a618d3b0e1fda6f3efcfec9 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 16 Dec 2025 12:38:47 +0100 Subject: [PATCH 08/16] Fix bad contrast on disabled dropdown menu items (#37268) --- app/javascript/styles/mastodon/components.scss | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 236efc40c8..47f17f6e79 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2848,7 +2848,7 @@ a.account__display-name { cursor: default; &:focus { - color: rgb(from var(--color-text-disabled) r g b / 70%); + color: var(--color-text-on-disabled); background: var(--color-bg-disabled); outline: 0; } @@ -3994,8 +3994,8 @@ a.account__display-name { box-sizing: border-box; &:hover, - &:focus, - &:active { + &:active, + &:focus-visible { color: var(--color-text-primary); } @@ -4013,14 +4013,7 @@ a.account__display-name { } &--logo { - background: transparent; padding: 10px; - - &:hover, - &:focus, - &:active { - background: transparent; - } } } From a9c84529b2c1c2ef3cf5c88521917834f2fe9e1c Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 16 Dec 2025 14:00:40 +0100 Subject: [PATCH 09/16] Wrapstodon: Load report data only on display (#37269) --- app/javascript/mastodon/actions/server.js | 5 ---- .../mastodon/features/annual_report/index.tsx | 10 ++++++- .../mastodon/features/annual_report/modal.tsx | 8 ++---- .../features/annual_report/nav_item.tsx | 4 +-- .../features/annual_report/timeline.tsx | 8 ++---- app/javascript/mastodon/features/ui/index.jsx | 2 ++ app/javascript/mastodon/initial_state.ts | 11 ++++---- .../mastodon/reducers/slices/annual_report.ts | 27 ++++++++----------- 8 files changed, 33 insertions(+), 42 deletions(-) diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index 47b6e7a176..32ee093afa 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -1,5 +1,3 @@ -import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report'; - import api from '../api'; import { importFetchedAccount } from './importer'; @@ -31,9 +29,6 @@ export const fetchServer = () => (dispatch, getState) => { .get('/api/v2/instance').then(({ data }) => { if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); dispatch(fetchServerSuccess(data)); - if (data.wrapstodon) { - void dispatch(checkAnnualReport()); - } }).catch(err => dispatch(fetchServerFail(err))); }; diff --git a/app/javascript/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx index fe55d250c6..218d60d26d 100644 --- a/app/javascript/mastodon/features/annual_report/index.tsx +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -10,6 +10,7 @@ import classNames from 'classnames/bind'; import { closeModal } from '@/mastodon/actions/modal'; import { IconButton } from '@/mastodon/components/icon_button'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; +import { getReport } from '@/mastodon/reducers/slices/annual_report'; import { createAppSelector, useAppDispatch, @@ -43,6 +44,13 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ const dispatch = useAppDispatch(); const report = useAppSelector((state) => state.annualReport.report); const account = useAppSelector(accountSelector); + const needsReport = !report; // Make into boolean to avoid object comparison in deps. + + useEffect(() => { + if (needsReport) { + void dispatch(getReport()); + } + }, [dispatch, needsReport]); const close = useCallback(() => { dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); @@ -57,7 +65,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ } }, [pathname, initialPathname, close]); - if (!report) { + if (needsReport) { return ; } diff --git a/app/javascript/mastodon/features/annual_report/modal.tsx b/app/javascript/mastodon/features/annual_report/modal.tsx index 953732cba5..01d7c4bbdb 100644 --- a/app/javascript/mastodon/features/annual_report/modal.tsx +++ b/app/javascript/mastodon/features/annual_report/modal.tsx @@ -4,10 +4,7 @@ import { useCallback, useEffect } from 'react'; import classNames from 'classnames'; import { closeModal } from '@/mastodon/actions/modal'; -import { - generateReport, - selectWrapstodonYear, -} from '@/mastodon/reducers/slices/annual_report'; +import { generateReport } from '@/mastodon/reducers/slices/annual_report'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { AnnualReport } from '.'; @@ -21,8 +18,7 @@ const AnnualReportModal: React.FC<{ onChangeBackgroundColor('var(--color-bg-media-base)'); }, [onChangeBackgroundColor]); - const { state } = useAppSelector((state) => state.annualReport); - const year = useAppSelector(selectWrapstodonYear); + const { state, year } = useAppSelector((state) => state.annualReport); const showAnnouncement = year && state && state !== 'available'; diff --git a/app/javascript/mastodon/features/annual_report/nav_item.tsx b/app/javascript/mastodon/features/annual_report/nav_item.tsx index bc293a7947..435e2b1f70 100644 --- a/app/javascript/mastodon/features/annual_report/nav_item.tsx +++ b/app/javascript/mastodon/features/annual_report/nav_item.tsx @@ -8,7 +8,6 @@ import classNames from 'classnames'; import IconPlanet from '@/images/icons/icon_planet.svg?react'; import { openModal } from '@/mastodon/actions/modal'; import { Icon } from '@/mastodon/components/icon'; -import { selectWrapstodonYear } from '@/mastodon/reducers/slices/annual_report'; import { createAppSelector, useAppDispatch, @@ -23,8 +22,7 @@ const selectReportModalOpen = createAppSelector( ); export const AnnualReportNavItem: FC = () => { - const { state } = useAppSelector((state) => state.annualReport); - const year = useAppSelector(selectWrapstodonYear); + const { state, year } = useAppSelector((state) => state.annualReport); const active = useAppSelector(selectReportModalOpen); const dispatch = useAppDispatch(); diff --git a/app/javascript/mastodon/features/annual_report/timeline.tsx b/app/javascript/mastodon/features/annual_report/timeline.tsx index 4280c2a98a..28a4b1d273 100644 --- a/app/javascript/mastodon/features/annual_report/timeline.tsx +++ b/app/javascript/mastodon/features/annual_report/timeline.tsx @@ -3,17 +3,13 @@ import type { FC } from 'react'; import { openModal } from '@/mastodon/actions/modal'; import { useDismissible } from '@/mastodon/hooks/useDismissible'; -import { - generateReport, - selectWrapstodonYear, -} from '@/mastodon/reducers/slices/annual_report'; +import { generateReport } from '@/mastodon/reducers/slices/annual_report'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { AnnualReportAnnouncement } from './announcement'; export const AnnualReportTimeline: FC = () => { - const { state } = useAppSelector((state) => state.annualReport); - const year = useAppSelector(selectWrapstodonYear); + const { state, year } = useAppSelector((state) => state.annualReport); const dispatch = useAppDispatch(); const handleBuildRequest = useCallback(() => { diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 0d73106808..61317fff5b 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -21,6 +21,7 @@ import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { layoutFromWindow } from 'mastodon/is_mobile'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; +import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { clearHeight } from '../../actions/height_cache'; @@ -396,6 +397,7 @@ class UI extends PureComponent { this.props.dispatch(expandHomeTimeline()); this.props.dispatch(fetchNotifications()); this.props.dispatch(fetchServerTranslationLanguages()); + this.props.dispatch(checkAnnualReport()); setTimeout(() => this.props.dispatch(fetchServer()), 3000); } diff --git a/app/javascript/mastodon/initial_state.ts b/app/javascript/mastodon/initial_state.ts index 358c307c30..9af0c26f93 100644 --- a/app/javascript/mastodon/initial_state.ts +++ b/app/javascript/mastodon/initial_state.ts @@ -1,12 +1,8 @@ +import type { ApiAnnualReportState } from './api/annual_report'; import type { ApiAccountJSON } from './api_types/accounts'; type InitialStateLanguage = [code: string, name: string, localName: string]; -interface InitialWrapstodonState { - year: number; - state: 'available' | 'generating' | 'eligible' | 'ineligible'; -} - interface InitialStateMeta { access_token: string; advanced_layout?: boolean; @@ -63,6 +59,11 @@ interface Role { highlighted: boolean; } +interface InitialWrapstodonState { + year: number; + state: ApiAnnualReportState; +} + export interface InitialState { accounts: Record; languages: InitialStateLanguage[]; diff --git a/app/javascript/mastodon/reducers/slices/annual_report.ts b/app/javascript/mastodon/reducers/slices/annual_report.ts index 5e7f44798a..798fe7cc9b 100644 --- a/app/javascript/mastodon/reducers/slices/annual_report.ts +++ b/app/javascript/mastodon/reducers/slices/annual_report.ts @@ -13,12 +13,10 @@ import { } from '@/mastodon/api/annual_report'; import { wrapstodon } from '@/mastodon/initial_state'; import type { AnnualReport } from '@/mastodon/models/annual_report'; - import { - createAppSelector, createAppThunk, createDataLoadingThunk, -} from '../../store/typed_functions'; +} from '@/mastodon/store/typed_functions'; interface AnnualReportState { year?: number; @@ -57,18 +55,17 @@ const annualReportSlice = createSlice({ export const annualReport = annualReportSlice.reducer; export const { setReport } = annualReportSlice.actions; -export const selectWrapstodonYear = createAppSelector( - [(state) => state.annualReport.year], - (year: number | null | undefined) => year ?? null, -); - -// This kicks everything off, and is called after fetching the server info. +// Called on initial load to check if we need to refresh the report state. export const checkAnnualReport = createAppThunk( `${annualReportSlice.name}/checkAnnualReport`, (_arg: unknown, { dispatch, getState }) => { - const year = selectWrapstodonYear(getState()); + const { state, year } = getState().annualReport; const me = getState().meta.get('me') as string; - if (!year || !me) { + + // If we have a state, we only need to fetch it again to poll for changes. + const needsStateRefresh = !state || state === 'generating'; + + if (!year || !me || !needsStateRefresh) { return; } void dispatch(fetchReportState()); @@ -78,7 +75,7 @@ export const checkAnnualReport = createAppThunk( const fetchReportState = createDataLoadingThunk( `${annualReportSlice.name}/fetchReportState`, async (_arg: unknown, { getState }) => { - const year = selectWrapstodonYear(getState()); + const { year } = getState().annualReport; if (!year) { throw new Error('Year is not set'); } @@ -89,8 +86,6 @@ const fetchReportState = createDataLoadingThunk( window.setTimeout(() => { void dispatch(fetchReportState()); }, 1_000 * refresh.retry); - } else if (state === 'available') { - void dispatch(getReport()); } return state; @@ -102,7 +97,7 @@ const fetchReportState = createDataLoadingThunk( export const generateReport = createDataLoadingThunk( `${annualReportSlice.name}/generateReport`, async (_arg: unknown, { getState }) => { - const year = selectWrapstodonYear(getState()); + const { year } = getState().annualReport; if (!year) { throw new Error('Year is not set'); } @@ -116,7 +111,7 @@ export const generateReport = createDataLoadingThunk( export const getReport = createDataLoadingThunk( `${annualReportSlice.name}/getReport`, async (_arg: unknown, { getState }) => { - const year = selectWrapstodonYear(getState()); + const { year } = getState().annualReport; if (!year) { throw new Error('Year is not set'); } From 92df1c4458b21a1efcfdf09e08598b8e995232c8 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 16 Dec 2025 10:05:26 -0500 Subject: [PATCH 10/16] Add coverage for `Account.representative` from finder concern (#35996) --- .../concerns/account/finder_concern_spec.rb | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/spec/models/concerns/account/finder_concern_spec.rb b/spec/models/concerns/account/finder_concern_spec.rb index b3fae56dfc..18d5c20475 100644 --- a/spec/models/concerns/account/finder_concern_spec.rb +++ b/spec/models/concerns/account/finder_concern_spec.rb @@ -3,6 +3,37 @@ require 'rails_helper' RSpec.describe Account::FinderConcern do + describe '.representative' do + context 'with an instance actor using an invalid legacy username' do + let(:legacy_value) { 'localhost:3000' } + + before { Account.find(Account::INSTANCE_ACTOR_ID).update_attribute(:username, legacy_value) } + + it 'updates the username to the new value' do + expect { Account.representative } + .to change { Account.find(Account::INSTANCE_ACTOR_ID).username }.from(legacy_value).to('mastodon.internal') + end + end + + context 'without an instance actor' do + before { Account.find(Account::INSTANCE_ACTOR_ID).destroy! } + + it 'creates an instance actor' do + expect { Account.representative } + .to change(Account.where(id: Account::INSTANCE_ACTOR_ID), :count).from(0).to(1) + end + end + + context 'with a correctly loaded instance actor' do + let(:instance_actor) { Account.find(Account::INSTANCE_ACTOR_ID) } + + it 'returns the instance actor record' do + expect(Account.representative) + .to eq(instance_actor) + end + end + end + describe 'local finders' do let!(:account) { Fabricate(:account, username: 'Alice') } From 7e81e035311db84ea031a4028c3f864f6ffe6867 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 16 Dec 2025 10:26:04 -0500 Subject: [PATCH 11/16] Reduce factory creation across `spec/helpers` (#35527) --- .../admin/account_moderation_notes_helper_spec.rb | 4 ++-- spec/helpers/admin/dashboard_helper_spec.rb | 10 ++-------- spec/helpers/admin/trends/statuses_helper_spec.rb | 2 +- spec/helpers/formatting_helper_spec.rb | 2 +- spec/helpers/home_helper_spec.rb | 2 +- spec/helpers/media_component_helper_spec.rb | 8 +++++--- spec/helpers/statuses_helper_spec.rb | 13 +++++-------- 7 files changed, 17 insertions(+), 24 deletions(-) diff --git a/spec/helpers/admin/account_moderation_notes_helper_spec.rb b/spec/helpers/admin/account_moderation_notes_helper_spec.rb index d8fc0ee233..a68c8abba9 100644 --- a/spec/helpers/admin/account_moderation_notes_helper_spec.rb +++ b/spec/helpers/admin/account_moderation_notes_helper_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Admin::AccountModerationNotesHelper do end context 'with account' do - let(:account) { Fabricate(:account) } + let(:account) { Fabricate.build(:account, id: 123) } it 'returns a labeled avatar link to the account' do expect(parsed_html.a[:href]).to eq admin_account_path(account.id) @@ -39,7 +39,7 @@ RSpec.describe Admin::AccountModerationNotesHelper do end context 'with account' do - let(:account) { Fabricate(:account) } + let(:account) { Fabricate.build(:account, id: 123) } it 'returns an inline link to the account' do expect(parsed_html.a[:href]).to eq admin_account_path(account.id) diff --git a/spec/helpers/admin/dashboard_helper_spec.rb b/spec/helpers/admin/dashboard_helper_spec.rb index 9c674fb4b9..db95eb9f2c 100644 --- a/spec/helpers/admin/dashboard_helper_spec.rb +++ b/spec/helpers/admin/dashboard_helper_spec.rb @@ -4,8 +4,9 @@ require 'rails_helper' RSpec.describe Admin::DashboardHelper do describe 'relevant_account_timestamp' do + let(:account) { Fabricate(:account) } + context 'with an account with older sign in' do - let(:account) { Fabricate(:account) } let(:stamp) { 10.days.ago } it 'returns a time element' do @@ -18,8 +19,6 @@ RSpec.describe Admin::DashboardHelper do end context 'with an account with newer sign in' do - let(:account) { Fabricate(:account) } - it 'returns a time element' do account.user.update(current_sign_in_at: 10.hours.ago) result = helper.relevant_account_timestamp(account) @@ -29,8 +28,6 @@ RSpec.describe Admin::DashboardHelper do end context 'with an account where the user is pending' do - let(:account) { Fabricate(:account) } - it 'returns a time element' do account.user.update(current_sign_in_at: nil) account.user.update(approved: false) @@ -42,7 +39,6 @@ RSpec.describe Admin::DashboardHelper do end context 'with an account with a last status value' do - let(:account) { Fabricate(:account) } let(:stamp) { 5.minutes.ago } it 'returns a time element' do @@ -56,8 +52,6 @@ RSpec.describe Admin::DashboardHelper do end context 'with an account without sign in or last status or pending' do - let(:account) { Fabricate(:account) } - it 'returns a time element' do account.user.update(current_sign_in_at: nil) result = helper.relevant_account_timestamp(account) diff --git a/spec/helpers/admin/trends/statuses_helper_spec.rb b/spec/helpers/admin/trends/statuses_helper_spec.rb index 6abc4569b4..634b4c94c3 100644 --- a/spec/helpers/admin/trends/statuses_helper_spec.rb +++ b/spec/helpers/admin/trends/statuses_helper_spec.rb @@ -54,7 +54,7 @@ RSpec.describe Admin::Trends::StatusesHelper do context 'with a status that has emoji' do before { Fabricate(:custom_emoji, shortcode: 'florpy') } - let(:status) { Fabricate(:status, text: 'hello there :florpy:') } + let(:status) { Fabricate.build(:status, text: 'hello there :florpy:') } it 'renders a correct preview text' do result = helper.one_line_preview(status) diff --git a/spec/helpers/formatting_helper_spec.rb b/spec/helpers/formatting_helper_spec.rb index 5ff534e4eb..4e605850c1 100644 --- a/spec/helpers/formatting_helper_spec.rb +++ b/spec/helpers/formatting_helper_spec.rb @@ -18,7 +18,7 @@ RSpec.describe FormattingHelper do end context 'with a spoiler and an emoji and a poll' do - let(:status) { Fabricate(:status, text: 'Hello :world: <>', spoiler_text: 'This is a spoiler<>', poll: Fabricate(:poll, options: %w(Yes<> No))) } + let(:status) { Fabricate(:status, text: 'Hello :world: <>', spoiler_text: 'This is a spoiler<>', poll: Fabricate.build(:poll, options: %w(Yes<> No))) } before { Fabricate :custom_emoji, shortcode: 'world' } diff --git a/spec/helpers/home_helper_spec.rb b/spec/helpers/home_helper_spec.rb index a056eae364..e63c03528c 100644 --- a/spec/helpers/home_helper_spec.rb +++ b/spec/helpers/home_helper_spec.rb @@ -21,7 +21,7 @@ RSpec.describe HomeHelper do end context 'with a valid account' do - let(:account) { Fabricate(:account) } + let(:account) { Fabricate.build(:account) } before { helper.extend controller_helpers } diff --git a/spec/helpers/media_component_helper_spec.rb b/spec/helpers/media_component_helper_spec.rb index a44b9b8415..60c9f84da2 100644 --- a/spec/helpers/media_component_helper_spec.rb +++ b/spec/helpers/media_component_helper_spec.rb @@ -5,8 +5,10 @@ require 'rails_helper' RSpec.describe MediaComponentHelper do before { helper.extend controller_helpers } + let(:media) { Fabricate.build(:media_attachment, type:, status: Fabricate.build(:status)) } + describe 'render_video_component' do - let(:media) { Fabricate(:media_attachment, type: :video, status: Fabricate(:status)) } + let(:type) { :video } let(:result) { helper.render_video_component(media.status) } it 'renders a react component for the video' do @@ -15,7 +17,7 @@ RSpec.describe MediaComponentHelper do end describe 'render_audio_component' do - let(:media) { Fabricate(:media_attachment, type: :audio, status: Fabricate(:status)) } + let(:type) { :audio } let(:result) { helper.render_audio_component(media.status) } it 'renders a react component for the audio' do @@ -24,7 +26,7 @@ RSpec.describe MediaComponentHelper do end describe 'render_media_gallery_component' do - let(:media) { Fabricate(:media_attachment, type: :audio, status: Fabricate(:status)) } + let(:type) { :audio } let(:result) { helper.render_media_gallery_component(media.status) } it 'renders a react component for the media gallery' do diff --git a/spec/helpers/statuses_helper_spec.rb b/spec/helpers/statuses_helper_spec.rb index 07ad72eda9..dbfc216605 100644 --- a/spec/helpers/statuses_helper_spec.rb +++ b/spec/helpers/statuses_helper_spec.rb @@ -24,16 +24,13 @@ RSpec.describe StatusesHelper do end describe '#media_summary' do - it 'describes the media on a status' do - status = Fabricate :status - Fabricate :media_attachment, status: status, type: :video - Fabricate :media_attachment, status: status, type: :audio - Fabricate :media_attachment, status: status, type: :image + subject { helper.media_summary(status) } - result = helper.media_summary(status) + let(:status) { Fabricate.build :status } - expect(result).to eq('Attached: 1 image · 1 video · 1 audio') - end + before { %i(video audio image).each { |type| Fabricate.build :media_attachment, status:, type: } } + + it { is_expected.to eq('Attached: 1 image · 1 video · 1 audio') } end describe 'visibility_icon' do From e6b0cdcc83750aee357f69f3ff41807b435fe083 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 16 Dec 2025 16:35:26 +0100 Subject: [PATCH 12/16] Updates Wrapstodon footer with dedicated local server info (#37270) --- app/helpers/wrapstodon_helper.rb | 1 + app/javascript/entrypoints/wrapstodon.tsx | 3 +- .../mastodon/features/annual_report/index.tsx | 2 +- .../annual_report/shared_page.module.scss | 35 ++++++--- .../features/annual_report/shared_page.tsx | 71 ++++++++++++------- app/javascript/mastodon/locales/en.json | 4 +- 6 files changed, 78 insertions(+), 38 deletions(-) diff --git a/app/helpers/wrapstodon_helper.rb b/app/helpers/wrapstodon_helper.rb index 8031c51179..5a0075a0e5 100644 --- a/app/helpers/wrapstodon_helper.rb +++ b/app/helpers/wrapstodon_helper.rb @@ -10,6 +10,7 @@ module WrapstodonHelper ).as_json payload[:me] = current_account.id.to_s if user_signed_in? + payload[:domain] = Addressable::IDNA.to_unicode(Rails.configuration.x.local_domain) json_string = payload.to_json diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx index e2c8d5a38e..9fff41a133 100644 --- a/app/javascript/entrypoints/wrapstodon.tsx +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -25,7 +25,7 @@ function loaded() { const initialState = JSON.parse( propsNode.textContent, - ) as ApiAnnualReportResponse & { me?: string }; + ) as ApiAnnualReportResponse & { me?: string; domain: string }; const report = initialState.annual_reports[0]; if (!report) { @@ -38,6 +38,7 @@ function loaded() { meta: { locale: document.documentElement.lang, me: initialState.me, + domain: initialState.domain, }, accounts: initialState.accounts, }), diff --git a/app/javascript/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx index 218d60d26d..ef6f73fff2 100644 --- a/app/javascript/mastodon/features/annual_report/index.tsx +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -27,7 +27,7 @@ import { NewPosts } from './new_posts'; const moduleClassNames = classNames.bind(styles); -const accountSelector = createAppSelector( +export const accountSelector = createAppSelector( [(state) => state.accounts, (state) => state.annualReport.report], (accounts, report) => { if (report?.schema_version === 2) { diff --git a/app/javascript/mastodon/features/annual_report/shared_page.module.scss b/app/javascript/mastodon/features/annual_report/shared_page.module.scss index b29ab51707..ea3ea471b9 100644 --- a/app/javascript/mastodon/features/annual_report/shared_page.module.scss +++ b/app/javascript/mastodon/features/annual_report/shared_page.module.scss @@ -16,22 +16,16 @@ $mobile-breakpoint: 540px; display: flex; flex-direction: column; align-items: center; - gap: 0.75rem; + gap: 1.8rem; margin-top: 2rem; font-size: 16px; + line-height: 1.4; text-align: center; color: var(--color-text-secondary); -} -.logo { - width: 2rem; - opacity: 0.6; -} - -.nav { - display: flex; - flex-wrap: wrap; - gap: 12px; + strong { + font-weight: 600; + } a:any-link { color: inherit; @@ -43,3 +37,22 @@ $mobile-breakpoint: 540px; color: var(--color-text-primary); } } + +.logo { + width: 2rem; + opacity: 0.6; +} + +.footerSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.linkList { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 12px; +} diff --git a/app/javascript/mastodon/features/annual_report/shared_page.tsx b/app/javascript/mastodon/features/annual_report/shared_page.tsx index f2b26bf2aa..3defe7194a 100644 --- a/app/javascript/mastodon/features/annual_report/shared_page.tsx +++ b/app/javascript/mastodon/features/annual_report/shared_page.tsx @@ -2,43 +2,66 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; +import { DisplayName } from '@/mastodon/components/display_name'; import { IconLogo } from '@/mastodon/components/logo'; import { useAppSelector } from '@/mastodon/store'; -import { AnnualReport } from './index'; +import { AnnualReport, accountSelector } from './index'; import classes from './shared_page.module.scss'; export const WrapstodonSharedPage: FC = () => { - const isLoggedIn = useAppSelector((state) => !!state.meta.get('me')); + const account = useAppSelector(accountSelector); + const domain = useAppSelector((state) => state.meta.get('domain') as string); return (
); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index bafbb3fafd..00b3029587 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -121,7 +121,7 @@ "annual_report.nav_item.badge": "New", "annual_report.shared_page.donate": "Donate", "annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team", - "annual_report.shared_page.sign_up": "Sign up", + "annual_report.shared_page.footer_server_info": "{username} uses {domain}, one of many communities powered by Mastodon.", "annual_report.summary.archetype.booster.desc_public": "{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.", "annual_report.summary.archetype.booster.desc_self": "You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.", "annual_report.summary.archetype.booster.name": "The Archer", @@ -441,6 +441,8 @@ "follow_suggestions.who_to_follow": "Who to follow", "followed_tags": "Followed hashtags", "footer.about": "About", + "footer.about_mastodon": "About Mastodon", + "footer.about_server": "About {domain}", "footer.about_this_server": "About", "footer.directory": "Profiles directory", "footer.get_app": "Get the app", From dbf8d77cbbf88b8c1cf8c3c3eaaf0c36a4b96693 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 16 Dec 2025 10:43:04 -0500 Subject: [PATCH 13/16] Add spec for missing username value in create account API (#37057) --- spec/requests/api/v1/accounts_spec.rb | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb index c6a131062b..e3416fc337 100644 --- a/spec/requests/api/v1/accounts_spec.rb +++ b/spec/requests/api/v1/accounts_spec.rb @@ -96,6 +96,28 @@ RSpec.describe '/api/v1/accounts' do end end + context 'when missing username value' do + subject do + post '/api/v1/accounts', headers: headers, params: { password: '12345678', email: 'hello@world.tld', agreement: 'true' } + end + + it 'returns http unprocessable entity with username error message' do + expect { subject } + .to not_change(User, :count) + .and not_change(Account, :count) + + expect(response) + .to have_http_status(422) + expect(response.media_type) + .to eq('application/json') + expect(response.parsed_body) + .to include( + error: /Validation failed/, + details: include(username: contain_exactly(include(error: 'ERR_BLANK', description: /can't be blank/))) + ) + end + end + context 'when age verification is enabled' do before do Setting.min_age = 16 From 95432b47ebd7ce0279482759175b39c08d21090f Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 16 Dec 2025 10:58:39 -0500 Subject: [PATCH 14/16] Add coverage for user model registration time validation (#35993) --- spec/models/user_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7088266b34..4ea2e6a79c 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -39,6 +39,15 @@ RSpec.describe User do end it { is_expected.to allow_value('admin@localhost').for(:email) } + + context 'when registration form time is present' do + subject { Fabricate.build :user } + + before { stub_const 'RegistrationFormTimeValidator::REGISTRATION_FORM_MIN_TIME', 3.seconds } + + it { is_expected.to allow_value(10.seconds.ago).for(:registration_form_time) } + it { is_expected.to_not allow_value(1.second.ago).for(:registration_form_time).against(:base) } + end end describe 'Normalizations' do From f118d613349bd43e915c5be58477c7d4d8175371 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 16 Dec 2025 17:06:59 +0100 Subject: [PATCH 15/16] Emojis: Show in embedded statuses (#37272) --- .../notifications_v2/components/embedded_status_content.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx index 9e7f66d112..845a6902a2 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx @@ -4,6 +4,7 @@ import type { List } from 'immutable'; import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; +import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; import type { Status } from '@/mastodon/models/status'; import type { Mention } from './embedded_status'; @@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{ className={className} lang={status.get('language') as string} htmlString={status.get('contentHtml') as string} + extraEmojis={status.get('emoji') as List} /> ); }; From 53be8392eceea8c3a576478e209fe82c2ceb458a Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 16 Dec 2025 11:13:03 -0500 Subject: [PATCH 16/16] Add coverage for blocked account scenario in following/followers (#36042) --- .../follower_accounts_controller_spec.rb | 20 +++++++++++++++++++ .../following_accounts_controller_spec.rb | 20 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/spec/controllers/follower_accounts_controller_spec.rb b/spec/controllers/follower_accounts_controller_spec.rb index d996761169..358341cb72 100644 --- a/spec/controllers/follower_accounts_controller_spec.rb +++ b/spec/controllers/follower_accounts_controller_spec.rb @@ -68,6 +68,26 @@ RSpec.describe FollowerAccountsController do end end + context 'when request is signed in and user blocks an account' do + let(:account) { Fabricate :account } + + before do + Fabricate :block, account:, target_account: follower_bob + sign_in(account.user) + end + + it 'returns followers without blocked' do + expect(response) + .to have_http_status(200) + expect(response.parsed_body) + .to include( + orderedItems: contain_exactly( + include(follow_from_chris.account.id.to_s) + ) + ) + end + end + context 'when account is permanently suspended' do before do alice.suspend! diff --git a/spec/controllers/following_accounts_controller_spec.rb b/spec/controllers/following_accounts_controller_spec.rb index 576d25d93c..7f11a50395 100644 --- a/spec/controllers/following_accounts_controller_spec.rb +++ b/spec/controllers/following_accounts_controller_spec.rb @@ -68,6 +68,26 @@ RSpec.describe FollowingAccountsController do end end + context 'when request is signed in and user blocks an account' do + let(:account) { Fabricate :account } + + before do + Fabricate :block, account:, target_account: followee_bob + sign_in(account.user) + end + + it 'returns followers without blocked' do + expect(response) + .to have_http_status(200) + expect(response.parsed_body) + .to include( + orderedItems: contain_exactly( + include(follow_of_chris.target_account.id.to_s) + ) + ) + end + end + context 'when account is permanently suspended' do before do alice.suspend!