diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ddca0bc239cd78..076b2f9b1e68a3 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -154,9 +154,15 @@ groupName: 'opentelemetry-ruby (non-major)', }, { - // Group Playwright Ruby & JS deps in the same PR, as they need to be in sync - matchManagers: ['bundler', 'npm'], - matchPackageNames: ['playwright-ruby-client', 'playwright'], + // The ruby portion of the Playwright group + matchManagers: ['bundler'], + matchPackageNames: ['playwright-ruby-client'], + groupName: 'Playwright', + }, + { + // The node portion of the Playwright group + matchManagers: ['npm'], + matchPackageNames: ['playwright'], groupName: 'Playwright', }, // Add labels depending on package manager diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 94c41c28737ace..f3881f0bde6a6d 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -21,5 +21,5 @@ jobs: - name: Set up Javascript environment uses: ./.github/actions/setup-javascript - - name: Check formatting with Prettier + - name: Check formatting run: yarn format:check diff --git a/.github/workflows/haml-lint-problem-matcher.json b/.github/workflows/haml-lint-problem-matcher.json deleted file mode 100644 index 3523ea29515a25..00000000000000 --- a/.github/workflows/haml-lint-problem-matcher.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "haml-lint", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.*):(\\d+)\\s\\[W]\\s(.*):\\s(.*)$", - "file": 1, - "line": 2, - "code": 3, - "message": 4 - } - ] - } - ] -} diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 9ce90430be2bef..faa963384fd9f9 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -45,5 +45,4 @@ jobs: - name: Run haml-lint run: | - echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" bin/haml-lint --reporter github diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000000..7d5225e2615eac --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,92 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "jsxSingleQuote": true, + "printWidth": 80, + "ignorePatterns": [ + "/tmp", + "/coverage", + "/public/assets", + "/public/emoji", + "/public/packs", + "/public/packs-test", + "/public/system", + "/public/vite*", + + "*.html", + "docker-compose.override.yml", + + // Ignore config YAML files that include ERB/ruby code + "config/email.yml", + + // Vendored CSS + "app/javascript/styles/mastodon/reset.scss", + + // Automatically generated + "/app/javascript/mastodon/features/emoji/emoji_map.json", + "/app/javascript/mastodon/features/emoji/emoji_data.json", + "AUTHORS.md", + "/app/javascript/mastodon/locales/*.json", + "/config/locales", + ".storybook/static/mockServiceWorker.js", + + // do not reformat JS files as this will change too many files and cause merge conflicts with open PRs and forks + "app/javascript/**/*.js", + "app/javascript/**/*.jsx", + "streaming/**/*.js" + ], + "experimentalSortPackageJson": false, + "experimentalSortImports": { + "groups": [ + ["builtin"], + ["react"], + ["react-intl"], + ["react-utils"], + ["redux"], + ["external", "type-external"], + ["internal", "type-internal"], + ["mastodon-internals"], + ["parent", "type-parent"], + ["sibling", "type-sibling", "index", "type-index"], + ["side_effect"] + ], + "customGroups": [ + { + "groupName": "react", + "elementNamePattern": [ + "react", + "react-dom", + "react-dom/client", + "prop-types" + ] + }, + { + "groupName": "react-intl", + "elementNamePattern": ["react-intl", "intl-messageformat"] + }, + { + "groupName": "react-utils", + "elementNamePattern": [ + "classnames", + "react-helmet", + "react-router", + "react-router-dom" + ] + }, + { + "groupName": "redux", + "elementNamePattern": [ + "immutable", + "@reduxjs/toolkit", + "react-redux", + "react-immutable-proptypes", + "react-immutable-pure-component" + ] + }, + { + "groupName": "mastodon-internals", + "elementNamePattern": ["mastodon/**", "@/**"] + } + ] + } +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 098dac6717786f..00000000000000 --- a/.prettierignore +++ /dev/null @@ -1,86 +0,0 @@ -# See https://help.github.com/articles/ignoring-files for more about ignoring files. -# -# If you find yourself ignoring temporary files generated by your text editor -# or operating system, you probably want to add a global ignore instead: -# git config --global core.excludesfile '~/.gitignore_global' - -# Ignore bundler config and downloaded libraries. -/.bundle -/vendor/bundle - -# Ignore the default SQLite database. -/db/*.sqlite3 -/db/*.sqlite3-journal - -# Ignore all logfiles and tempfiles. -.eslintcache -/log/* -!/log/.keep -/tmp -/coverage -.env -.env.production -.env.development -/node_modules/ -/build/ - -# Ignore Vagrant files -.vagrant/ - -# Ignore IDE files -.vscode/ -.idea/ - -# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose -/postgres -/postgres14 -/redis -/elasticsearch - -# Ignore Apple files -.DS_Store - -# Ignore vim files -*~ -*.swp - -# Ignore log files -*.log - -# Ignore Docker option files -docker-compose.override.yml - -# Ignore public -/public/assets -/public/emoji -/public/packs -/public/packs-test -/public/system -/public/vite* - -# Ignore emoji map file -/app/javascript/mastodon/features/emoji/emoji_map.json -/app/javascript/mastodon/features/emoji/emoji_data.json - -# Ignore locale files -/app/javascript/mastodon/locales/*.json -/config/locales - -# Ignore vendored CSS reset -app/javascript/styles/mastodon/reset.scss - -# Ignore Javascript pending https://github.com/mastodon/mastodon/pull/23631 -*.js -*.jsx - -# Ignore HTML till cleaned and included in CI -*.html - -# Ignore the generated AUTHORS.md -AUTHORS.md - -# Process a few selected JS files -!lint-staged.config.js - -# Ignore config YAML files that include ERB/ruby code prettier does not understand -/config/email.yml diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 65ec869c338d9d..00000000000000 --- a/.prettierrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - singleQuote: true, - jsxSingleQuote: true -}; diff --git a/.rubocop/layout.yml b/.rubocop/layout.yml index 487879ca2c1134..93966749952bc2 100644 --- a/.rubocop/layout.yml +++ b/.rubocop/layout.yml @@ -4,3 +4,6 @@ Layout/FirstHashElementIndentation: Layout/LineLength: Max: 300 # Default of 120 causes a duplicate entry in generated todo file + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index efdebc3bda3643..1067b0e0414a13 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -21,9 +21,10 @@ import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; +import { modes } from './modes'; + import '../app/javascript/styles/application.scss'; import './styles.css'; -import { modes } from './modes'; const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { query: { as: 'json' }, diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbc450d74a19a..c6c0ff20d1aba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [4.5.7] - 2026-02-24 + +### Security + +- Reject unconfirmed FASPs (#37926 by @oneiros, [GHSA-qgmm-vr4c-ggjg](https://github.com/mastodon/mastodon/security/advisories/GHSA-qgmm-vr4c-ggjg)) +- Re-use custom socket class for FASP requests (#37925 by @oneiros, [GHSA-46w6-g98f-wxqm](https://github.com/mastodon/mastodon/security/advisories/GHSA-46w6-g98f-wxqm)) + +### Added + +- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski) + +### Fixed + +- Fix emoji data not being properly cached (#37858 by @ChaosExAnima) +- Fix delete & redraft of pending posts (#37839 by @ClearlyClaire) +- Fix processing separate key documents without the ActivityStreams context (#37826 by @ClearlyClaire) +- Fix custom emojis not being purged on domain suspension (#37808 by @ClearlyClaire) +- Fix users without special permissions being able to stream disabled timelines (#37791 by @ClearlyClaire) +- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire) + ## [4.5.6] - 2026-02-03 ### Security diff --git a/Gemfile.lock b/Gemfile.lock index d734898522251e..8feb1490d03436 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -187,7 +187,7 @@ GEM irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) - devise (5.0.1) + devise (5.0.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 7.0) @@ -470,7 +470,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.0) + nokogiri (1.19.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.15) @@ -488,7 +488,7 @@ GEM omniauth-rails_csrf_protection (2.0.1) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.2.4) + omniauth-saml (2.2.5) omniauth (~> 2.1) ruby-saml (~> 1.18) omniauth_openid_connect (0.8.0) @@ -631,7 +631,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-attack (6.8.0) rack (>= 1.0, < 4) rack-cors (3.0.0) @@ -741,7 +741,7 @@ GEM rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.2) + rspec-rails (8.0.3) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) @@ -749,13 +749,13 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-sidekiq (5.2.0) + rspec-sidekiq (5.3.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.7) - rubocop (1.84.0) + rubocop (1.84.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) diff --git a/app/chewy/public_statuses_index.rb b/app/chewy/public_statuses_index.rb index b71406d3e345dd..ee07b1c6d78ea1 100644 --- a/app/chewy/public_statuses_index.rb +++ b/app/chewy/public_statuses_index.rb @@ -8,9 +8,9 @@ class PublicStatusesIndex < Chewy::Index settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: ChewyConfig.instance.public_statuses index_scope ::Status.unscoped - .kept - .indexable - .includes(:media_attachments, :preloadable_poll, :tags, :account, preview_cards_status: :preview_card) + .kept + .indexable + .includes(:media_attachments, :preloadable_poll, :tags, :account, preview_cards_status: :preview_card) root date_detection: false do field(:id, type: 'long') diff --git a/app/controllers/admin/collections_controller.rb b/app/controllers/admin/collections_controller.rb new file mode 100644 index 00000000000000..4701500f9f8691 --- /dev/null +++ b/app/controllers/admin/collections_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Admin + class CollectionsController < BaseController + before_action :set_account + before_action :set_collection, only: :show + + def show + authorize @collection, :show? + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def set_collection + @collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id]) + end + end +end diff --git a/app/controllers/admin/fasp/debug/callbacks_controller.rb b/app/controllers/admin/fasp/debug/callbacks_controller.rb index 28aba5e48925b1..acba4c51d83f6f 100644 --- a/app/controllers/admin/fasp/debug/callbacks_controller.rb +++ b/app/controllers/admin/fasp/debug/callbacks_controller.rb @@ -5,8 +5,8 @@ def index authorize [:admin, :fasp, :provider], :update? @callbacks = Fasp::DebugCallback - .includes(:fasp_provider) - .order(created_at: :desc) + .includes(:fasp_provider) + .order(created_at: :desc) end def destroy diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index aa877f1448c98e..44ee7206bf7bc7 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -50,7 +50,7 @@ def resolve private def filtered_reports - ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account) + ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account, :collections) end def filter_params @@ -58,7 +58,7 @@ def filter_params end def set_report - @report = Report.find(params[:id]) + @report = Report.includes(collections: :accepted_collection_items).find(params[:id]) end end end diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb index f786ea1767fcce..a05d0049bab53e 100644 --- a/app/controllers/api/fasp/base_controller.rb +++ b/app/controllers/api/fasp/base_controller.rb @@ -47,7 +47,7 @@ def validate_signature! provider = nil Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid| - provider = Fasp::Provider.find(keyid) + provider = Fasp::Provider.confirmed.find(keyid) Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid) end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index d7516c927bc714..e79b292e5f0efc 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -18,14 +18,14 @@ def load_accounts def paginated_blocks @paginated_blocks ||= Block.eager_load(target_account: [:account_stat, :user]) - .joins(:target_account) - .merge(Account.without_suspended) - .where(account: current_account) - .paginate_by_max_id( - limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id] - ) + .joins(:target_account) + .merge(Account.without_suspended) + .where(account: current_account) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) end def next_path diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index 60db082a8e71a1..5f09d0c8864329 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -37,20 +37,20 @@ def set_conversation def paginated_conversations AccountConversation.where(account: current_account) - .includes( - account: [:account_stat, user: :role], - last_status: [ - :media_attachments, - :status_stat, - :tags, - { - preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, - active_mentions: :account, - account: [:account_stat, user: :role], - }, - ] - ) - .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + .includes( + account: [:account_stat, user: :role], + last_status: [ + :media_attachments, + :status_stat, + :tags, + { + preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, + active_mentions: :account, + account: [:account_stat, user: :role], + }, + ] + ) + .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end def next_path diff --git a/app/controllers/api/v1/donation_campaigns_controller.rb b/app/controllers/api/v1/donation_campaigns_controller.rb new file mode 100644 index 00000000000000..cdd7503b304659 --- /dev/null +++ b/app/controllers/api/v1/donation_campaigns_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class Api::V1::DonationCampaignsController < Api::BaseController + before_action :require_user! + + STOPLIGHT_COOL_OFF_TIME = 60 + STOPLIGHT_FAILURE_THRESHOLD = 10 + + def index + return head 204 if api_url.blank? + + json = from_cache + return render json: json if json.present? + + campaign = fetch_campaign + return head 204 if campaign.nil? + + save_to_cache!(campaign) + + render json: campaign + end + + private + + def api_url + Rails.configuration.x.donation_campaigns.api_url + end + + def seed + @seed ||= Random.new(current_account.id).rand(100) + end + + def from_cache + key = Rails.cache.read(request_key, raw: true) + return if key.blank? + + campaign = Rails.cache.read("donation_campaign:#{key}", raw: true) + Oj.load(campaign) if campaign.present? + end + + def save_to_cache!(campaign) + return if campaign.blank? + + Rails.cache.write_multi( + { + request_key => campaign_key(campaign), + "donation_campaign:#{campaign_key(campaign)}" => Oj.dump(campaign), + }, + expires_in: 1.hour, + raw: true + ) + end + + def fetch_campaign + stoplight_wrapper.run do + url = Addressable::URI.parse(api_url) + url.query_values = { platform: 'web', seed: seed, locale: locale, environment: Rails.configuration.x.donation_campaigns.environment }.compact + + Request.new(:get, url.to_s).perform do |res| + return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200 + end + end + rescue *Mastodon::HTTP_CONNECTION_ERRORS, Oj::ParseError + nil + end + + def stoplight_wrapper + Stoplight( + 'donation_campaigns', + cool_off_time: STOPLIGHT_COOL_OFF_TIME, + threshold: STOPLIGHT_FAILURE_THRESHOLD + ) + end + + def request_key + "donation_campaign_request:#{seed}:#{locale}" + end + + def campaign_key(campaign) + "#{campaign['id']}:#{campaign['locale']}" + end + + def locale + I18n.locale.to_s + end +end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index d2b50e333662a3..2c213ca20217df 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -18,14 +18,14 @@ def load_accounts def paginated_mutes @paginated_mutes ||= Mute.eager_load(target_account: [:account_stat, :user]) - .joins(:target_account) - .merge(Account.without_suspended) - .where(account: current_account) - .paginate_by_max_id( - limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id] - ) + .joins(:target_account) + .merge(Account.without_suspended) + .where(account: current_account) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) end def next_path diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb new file mode 100644 index 00000000000000..196d0ef3a7012a --- /dev/null +++ b/app/controllers/api/v1/profiles_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Api::V1::ProfilesController < Api::BaseController + before_action -> { doorkeeper_authorize! :profile, :read, :'read:accounts' }, except: [:update] + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:update] + before_action :require_user! + + def show + @account = current_account + render json: @account, serializer: REST::ProfileSerializer + end + + def update + @account = current_account + UpdateAccountService.new.call(@account, account_params, raise_error: true) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) + + render json: @account, serializer: REST::ProfileSerializer + rescue ActiveRecord::RecordInvalid => e + render json: ValidationErrorFormatter.new(e).as_json, status: 422 + end + + def account_params + params.permit( + :display_name, + :note, + :avatar, + :header, + :locked, + :bot, + :discoverable, + :hide_collections, + :indexable, + :show_media, + :show_media_replies, + :show_featured, + attribution_domains: [], + fields_attributes: [:name, :value] + ) + end +end diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb index 1f103beb719a38..aedfc1dd9a6663 100644 --- a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -23,7 +23,7 @@ def destroy if emoji shortcode, domain = emoji.split('@') emoji_reaction = EmojiReaction.where(account_id: current_account.id).where(status_id: @status.id).where(name: shortcode) - .find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain } + .find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain } authorize @status, :show? if emoji_reaction.nil? diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index feea6c6b32e639..1ca1cd6923f0a9 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -72,10 +72,10 @@ def set_account def set_collections @collections = @account.collections - .with_tag - .order(created_at: :desc) - .offset(offset_param) - .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + .with_tag + .order(created_at: :desc) + .offset(offset_param) + .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) @collections = @collections.discoverable unless @account == current_account end diff --git a/app/helpers/ng_rule_helper.rb b/app/helpers/ng_rule_helper.rb index cb6ee64a85fec7..b9f7e5ae5990b8 100644 --- a/app/helpers/ng_rule_helper.rb +++ b/app/helpers/ng_rule_helper.rb @@ -13,8 +13,8 @@ def check_invalid_reaction_for_ng_rule!(account, **options) def check_for_ng_rule!(account, **options, &block) NgRule.cached_rules - .map { |raw_rule| Admin::NgRule.new(raw_rule, account, **options) } - .filter(&block) + .map { |raw_rule| Admin::NgRule.new(raw_rule, account, **options) } + .filter(&block) end def do_account_action_for_rule!(account, action) diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index 55b31deecfe2b0..5501fb5daeb045 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -328,9 +328,8 @@ async function mountReactComponent(element: Element) { const componentProps = JSON.parse(stringProps) as object; - const { default: AdminComponent } = await import( - '@/mastodon/containers/admin_component' - ); + const { default: AdminComponent } = + await import('@/mastodon/containers/admin_component'); const { default: Component } = (await import( `@/mastodon/components/admin/${componentName}.jsx` diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 6e88eb8778068a..b2a1473dfbc46c 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -366,9 +366,9 @@ on('change', '#account_statuses_cleanup_policy_enabled', ({ target }) => { if (!(target instanceof HTMLInputElement) || !target.form) return; target.form - .querySelectorAll< - HTMLInputElement | HTMLSelectElement - >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select') + .querySelectorAll( + 'input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select', + ) .forEach((input) => { setInputDisabled(input, !target.checked); }); diff --git a/app/javascript/images/icons/icon_verified.svg b/app/javascript/images/icons/icon_verified.svg index 65873b9dc43495..62bdcb57102428 100644 --- a/app/javascript/images/icons/icon_verified.svg +++ b/app/javascript/images/icons/icon_verified.svg @@ -2,9 +2,9 @@ - - - + + + diff --git a/app/javascript/mastodon/actions/importer/emoji.ts b/app/javascript/mastodon/actions/importer/emoji.ts index eafe612f382aa9..9e06c88f66e0bd 100644 --- a/app/javascript/mastodon/actions/importer/emoji.ts +++ b/app/javascript/mastodon/actions/importer/emoji.ts @@ -7,9 +7,8 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { } // First, check if we already have them all. - const { searchCustomEmojisByShortcodes, clearCache } = await import( - '@/mastodon/features/emoji/database' - ); + const { searchCustomEmojisByShortcodes, clearCache } = + await import('@/mastodon/features/emoji/database'); const existingEmojis = await searchCustomEmojisByShortcodes( emojis.map((emoji) => emoji.shortcode), diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index fb99978cadab0b..9c35d619a4cb9f 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -1,10 +1,13 @@ -import { apiRequestPost, apiRequestGet } from 'mastodon/api'; +import { apiRequestPost, apiRequestGet, apiRequestDelete } from 'mastodon/api'; import type { ApiAccountJSON, ApiFamiliarFollowersJSON, } from 'mastodon/api_types/accounts'; import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships'; -import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; +import type { + ApiFeaturedTagJSON, + ApiHashtagJSON, +} from 'mastodon/api_types/tags'; export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { @@ -30,7 +33,19 @@ export const apiRemoveAccountFromFollowers = (id: string) => ); export const apiGetFeaturedTags = (id: string) => - apiRequestGet(`v1/accounts/${id}/featured_tags`); + apiRequestGet(`v1/accounts/${id}/featured_tags`); + +export const apiGetCurrentFeaturedTags = () => + apiRequestGet(`v1/featured_tags`); + +export const apiPostFeaturedTag = (name: string) => + apiRequestPost('v1/featured_tags', { name }); + +export const apiDeleteFeaturedTag = (id: string) => + apiRequestDelete(`v1/featured_tags/${id}`); + +export const apiGetTagSuggestions = () => + apiRequestGet('v1/featured_tags/suggestions'); export const apiGetEndorsedAccounts = (id: string) => apiRequestGet(`v1/accounts/${id}/endorsements`); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 2b1d4f7a738e87..e89516c0e47c81 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -37,6 +37,26 @@ export interface ApiServerFeaturesJSON { legacy_quote: boolean; } +type ApiFeaturePolicy = + | 'public' + | 'followers' + | 'following' + | 'disabled' + | 'unsupported_policy'; + +type ApiUserFeaturePolicy = + | 'automatic' + | 'manual' + | 'denied' + | 'missing' + | 'unknown'; + +interface ApiFeaturePolicyJSON { + automatic: ApiFeaturePolicy[]; + manual: ApiFeaturePolicy[]; + current_user: ApiUserFeaturePolicy; +} + // See app/serializers/rest/account_serializer.rb export interface BaseApiAccountJSON { acct: string; @@ -48,6 +68,7 @@ export interface BaseApiAccountJSON { indexable: boolean; display_name: string; emojis: ApiCustomEmojiJSON[]; + feature_approval: ApiFeaturePolicyJSON; fields: ApiAccountFieldJSON[]; followers_count: number; following_count: number; diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index 51b87014746ddc..23f835f5fc91c6 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -11,14 +11,14 @@ export interface ApiCollectionJSON { account_id: string; id: string; - uri: string; + uri: string | null; local: boolean; item_count: number; name: string; description: string; - tag?: ApiTagJSON; - language: string; + tag: ApiTagJSON | null; + language: string | null; sensitive: boolean; discoverable: boolean; @@ -45,8 +45,7 @@ export interface ApiWrappedCollectionJSON { /** * Returned when fetching a single collection */ -export interface ApiCollectionWithAccountsJSON - extends ApiWrappedCollectionJSON { +export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON { accounts: ApiAccountJSON[]; } @@ -76,8 +75,7 @@ type CommonPayloadFields = Pick< language?: ApiCollectionJSON['language']; }; -export interface ApiUpdateCollectionPayload - extends Partial { +export interface ApiUpdateCollectionPayload extends Partial { id: string; } diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index ff61840c48696d..29fa95fad4f718 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -130,8 +130,7 @@ export interface ApiAccountWarningJSON { appeal: unknown; } -interface ModerationWarningNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface ModerationWarningNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'moderation_warning'; moderation_warning: ApiAccountWarningJSON; } @@ -151,14 +150,12 @@ export interface ApiAccountRelationshipSeveranceEventJSON { created_at: string; } -interface AccountRelationshipSeveranceNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface AccountRelationshipSeveranceNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } -interface AccountRelationshipSeveranceNotificationJSON - extends BaseNotificationJSON { +interface AccountRelationshipSeveranceNotificationJSON extends BaseNotificationJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts index 3066b4f1f1b82e..01d7f9e4b616da 100644 --- a/app/javascript/mastodon/api_types/tags.ts +++ b/app/javascript/mastodon/api_types/tags.ts @@ -4,11 +4,29 @@ interface ApiHistoryJSON { uses: string; } -export interface ApiHashtagJSON { +interface ApiHashtagBase { id: string; name: string; url: string; +} + +export interface ApiHashtagJSON extends ApiHashtagBase { history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; following?: boolean; featuring?: boolean; } + +export interface ApiFeaturedTagJSON extends ApiHashtagBase { + statuses_count: number; + 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-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap index c25dc59c868018..1b6647f91f1e0b 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > Autoplay > renders a animated avatar 1`] = ` -
> Autoplay > renders a animated avatar 1`] = ` onLoad={[Function]} src="/animated/alice.gif" /> -
+ `; exports[` > Still > renders a still avatar 1`] = ` -
> Still > renders a still avatar 1`] = ` onLoad={[Function]} src="/static/alice.jpg" /> -
+ `; diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index b67a827b2cda18..f5c7ffc34ac89b 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; + extraAccountInfo?: React.ReactNode; children?: React.ReactNode; } @@ -90,6 +91,7 @@ export const Account: React.FC = ({ hideButtons, childrenA, withMenu = true, + extraAccountInfo, children, }) => { const intl = useIntl(); @@ -307,7 +309,7 @@ export const Account: React.FC = ({ >
= ({ />
))} + + {extraAccountInfo} {!minimal && childrenA && ( diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index 6d4ab1ddd49b2c..75067530c946eb 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -6,7 +6,7 @@ import { EmojiHTML } from './emoji/html'; import { useElementHandledLink } from './status/handled_link'; interface AccountBioProps { - className: string; + className?: string; accountId: string; showDropdown?: boolean; } diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx index 33dd3963d21c9a..1aa847b65f8114 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -2,11 +2,11 @@ import { useState, useCallback, useRef, useId } from 'react'; import { FormattedMessage } from 'react-intl'; -import Overlay from 'react-overlays/Overlay'; import type { OffsetValue, UsePopperOptions, } from 'react-overlays/esm/usePopper'; +import Overlay from 'react-overlays/Overlay'; import { useSelectableClick } from 'mastodon/hooks/useSelectableClick'; diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx index 2ae66ffa1160d9..6e1c5dbfd4ca98 100644 --- a/app/javascript/mastodon/components/avatar.tsx +++ b/app/javascript/mastodon/components/avatar.tsx @@ -53,7 +53,7 @@ export const Avatar: React.FC = ({ }, [setError]); const avatar = ( -
= ({ )} {counter && ( -
{counter} -
+ )} -
+ ); if (withLink) { diff --git a/app/javascript/mastodon/components/button/index.tsx b/app/javascript/mastodon/components/button/index.tsx index ca2da05b703f8d..a75449b0d58399 100644 --- a/app/javascript/mastodon/components/button/index.tsx +++ b/app/javascript/mastodon/components/button/index.tsx @@ -5,8 +5,10 @@ import classNames from 'classnames'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -interface BaseProps - extends Omit, 'children'> { +interface BaseProps extends Omit< + React.ButtonHTMLAttributes, + 'children' +> { block?: boolean; secondary?: boolean; plain?: boolean; diff --git a/app/javascript/mastodon/components/column.tsx b/app/javascript/mastodon/components/column.tsx index 01c75d85c008cb..2abe3425e4c722 100644 --- a/app/javascript/mastodon/components/column.tsx +++ b/app/javascript/mastodon/components/column.tsx @@ -1,6 +1,8 @@ import { forwardRef, useRef, useImperativeHandle } from 'react'; import type { Ref } from 'react'; +import classNames from 'classnames'; + import { scrollTop } from 'mastodon/scroll'; export interface ColumnRef { @@ -12,10 +14,11 @@ interface ColumnProps { children?: React.ReactNode; label?: string; bindToDocument?: boolean; + className?: string; } export const Column = forwardRef( - ({ children, label, bindToDocument }, ref: Ref) => { + ({ children, label, bindToDocument, className }, ref: Ref) => { const nodeRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -39,7 +42,12 @@ export const Column = forwardRef( })); return ( -
+
{children}
); diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx index 06ba29cd26e39c..64273ab2147e57 100644 --- a/app/javascript/mastodon/components/column_header.tsx +++ b/app/javascript/mastodon/components/column_header.tsx @@ -73,6 +73,7 @@ export interface Props { iconComponent?: IconProp; active?: boolean; children?: React.ReactNode; + className?: string; pinned?: boolean; multiColumn?: boolean; extraButton?: React.ReactNode; @@ -91,6 +92,7 @@ export const ColumnHeader: React.FC = ({ iconComponent, active, children, + className, pinned, multiColumn, extraButton, @@ -141,7 +143,7 @@ export const ColumnHeader: React.FC = ({ onPin?.(); }, [history, pinned, onPin]); - const wrapperClassName = classNames('column-header__wrapper', { + const wrapperClassName = classNames('column-header__wrapper', className, { active, }); @@ -256,7 +258,8 @@ export const ColumnHeader: React.FC = ({ } const hasIcon = icon && iconComponent; - const hasTitle = hasIcon && title; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const hasTitle = (hasIcon || backButton) && title; const component = (
@@ -270,7 +273,7 @@ export const ColumnHeader: React.FC = ({ className='column-header__title' type='button' > - {!backButton && ( + {!backButton && hasIcon && ( = ({ ); }; -export const DropdownMenu = ({ +export const DropdownMenu = ({ items, loading, scrollable, @@ -312,10 +312,11 @@ interface DropdownProps { status?: ImmutableMap; needsStatusRefresh?: boolean; forceDropdown?: boolean; + className?: string; renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onOpen?: // Must use a union type for the full function as a union with void is not allowed. - | ((event: React.MouseEvent | React.KeyboardEvent) => void) + | ((event: React.MouseEvent | React.KeyboardEvent) => void) | ((event: React.MouseEvent | React.KeyboardEvent) => boolean); onItemClick?: ItemClickFn; } @@ -337,6 +338,7 @@ export const Dropdown = ({ status, needsStatusRefresh, forceDropdown = false, + className, renderItem, renderHeader, onOpen, @@ -436,6 +438,7 @@ export const Dropdown = ({ modalProps: { actions: items, onClick: handleItemClick, + className, }, }), ); @@ -464,6 +467,7 @@ export const Dropdown = ({ handleClose, statusId, needsStatusRefresh, + className, ], ); @@ -517,7 +521,7 @@ export const Dropdown = ({ popperConfig={popperConfig} > {({ props, arrowProps, placement }) => ( -
+
+ {label} + + ); + } + return ( {label} diff --git a/app/javascript/mastodon/components/form_fields/combobox.module.scss b/app/javascript/mastodon/components/form_fields/combobox.module.scss index 68c091a6d2fee1..7947b698a563fe 100644 --- a/app/javascript/mastodon/components/form_fields/combobox.module.scss +++ b/app/javascript/mastodon/components/form_fields/combobox.module.scss @@ -3,7 +3,7 @@ } .input { - padding-right: 45px; + padding-inline-end: 45px; } .menuButton { diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx index 412428d345f0a5..2c4b82fdcda6d8 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx @@ -82,11 +82,23 @@ const ComboboxDemo: React.FC = () => { const meta = { title: 'Components/Form Fields/ComboboxField', - component: ComboboxDemo, -} satisfies Meta; + component: ComboboxField, + render: () => , +} satisfies Meta; export default meta; type Story = StoryObj; -export const Example: Story = {}; +export const Example: Story = { + args: { + // Adding these types to keep TS happy, they're not passed on to `ComboboxDemo` + label: '', + value: '', + onChange: () => undefined, + items: [], + getItemId: () => '', + renderItem: () => <>Nothing, + onSelectItem: () => undefined, + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx index 13295d3b8fad47..0c3af8088381cc 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -1,4 +1,3 @@ -import type { ComponentPropsWithoutRef } from 'react'; import { forwardRef, useCallback, useId, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -9,6 +8,7 @@ import Overlay from 'react-overlays/Overlay'; import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import { matchWidth } from 'mastodon/components/dropdown/utils'; import { IconButton } from 'mastodon/components/icon_button'; import { useOnClickOutside } from 'mastodon/hooks/useOnClickOutside'; @@ -17,6 +17,7 @@ import classes from './combobox.module.scss'; import { FormFieldWrapper } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper'; import { TextInput } from './text_input_field'; +import type { TextInputProps } from './text_input_field'; interface ComboboxItem { id: string; @@ -27,26 +28,58 @@ export interface ComboboxItemState { isDisabled: boolean; } -interface ComboboxProps - extends ComponentPropsWithoutRef<'input'> { +interface ComboboxProps extends TextInputProps { + /** + * The value of the combobox's text input + */ value: string; + /** + * Change handler for the text input field + */ onChange: React.ChangeEventHandler; + /** + * Set this to true when the list of options is dynamic and currently loading. + * Causes a loading indicator to be displayed inside of the dropdown menu. + */ isLoading?: boolean; + /** + * The set of options/suggestions that should be rendered in the dropdown menu. + */ items: T[]; - getItemId: (item: T) => string; + /** + * A function that must return a unique id for each option passed via `items` + */ + getItemId?: (item: T) => string; + /** + * Providing this function turns the combobox into a multi-select box that assumes + * multiple options to be selectable. Single-selection is handled automatically. + */ getIsItemSelected?: (item: T) => boolean; + /** + * Use this function to mark items as disabled, if needed + */ getIsItemDisabled?: (item: T) => boolean; + /** + * Customise the rendering of each option. + * The rendered content must not contain other interactive content! + */ renderItem: (item: T, state: ComboboxItemState) => React.ReactElement; + /** + * The main selection handler, called when an option is selected or deselected. + */ onSelectItem: (item: T) => void; } interface Props - extends ComboboxProps, - CommonFieldWrapperProps {} + extends ComboboxProps, CommonFieldWrapperProps {} /** - * The combobox field allows users to select one or multiple items - * from a large list of options by searching or filtering. + * The combobox field allows users to select one or more items + * by searching or filtering a large or dynamic list of options. + * + * It is an implementation of the [APG Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), + * with inspiration taken from Sarah Higley's extensive combobox + * [research & implementations](https://sarahmhigley.com/writing/select-your-poison/). */ export const ComboboxFieldWithRef = ( @@ -80,7 +113,7 @@ const ComboboxWithRef = ( value, isLoading = false, items, - getItemId, + getItemId = (item) => item.id, getIsItemDisabled, getIsItemSelected, disabled, @@ -88,6 +121,7 @@ const ComboboxWithRef = ( onSelectItem, onChange, onKeyDown, + icon = SearchIcon, className, ...otherProps }: ComboboxProps, @@ -306,6 +340,7 @@ const ComboboxWithRef = ( value={value} onChange={handleInputChange} onKeyDown={handleInputKeyDown} + icon={icon} className={classNames(classes.input, className)} ref={mergeRefs} /> diff --git a/app/javascript/mastodon/components/form_fields/select_field.tsx b/app/javascript/mastodon/components/form_fields/select_field.tsx index 6aae7167605e2f..59854b578e0aee 100644 --- a/app/javascript/mastodon/components/form_fields/select_field.tsx +++ b/app/javascript/mastodon/components/form_fields/select_field.tsx @@ -8,8 +8,7 @@ import type { CommonFieldWrapperProps } from './form_field_wrapper'; import classes from './select.module.scss'; interface Props - extends ComponentPropsWithoutRef<'select'>, - CommonFieldWrapperProps {} + extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {} /** * A simple form field for single-item selections. diff --git a/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx b/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx index 448af8a28ebe94..190239aee2a752 100644 --- a/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx @@ -42,6 +42,13 @@ export const WithError: Story = { }, }; +export const AutoSize: Story = { + args: { + autoSize: true, + defaultValue: 'This textarea will grow as you type more lines.', + }, +}; + export const Plain: Story = { render(args) { return