diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f9148e36c4..b52400441d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -6,10 +6,6 @@ # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -Lint/NonLocalExitFromIterator: - Exclude: - - 'app/helpers/json_ld_helper.rb' - # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 90 diff --git a/Gemfile.lock b/Gemfile.lock index d0472d538c..b8813b7211 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.1131.0) + aws-partitions (1.1135.0) aws-sdk-core (3.215.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -233,7 +233,7 @@ GEM fabrication (3.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.2) + faraday (2.13.4) faraday-net_http (>= 2.0, < 3.5) json logger @@ -345,7 +345,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.13.0) + json (2.13.2) json-canonicalization (1.0.0) json-jwt (1.16.7) activesupport (>= 4.2) @@ -438,7 +438,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0715) + mime-types-data (3.2025.0722) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (5.25.5) @@ -468,7 +468,7 @@ GEM hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-cas (3.0.1) + omniauth-cas (3.0.2) addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) @@ -601,13 +601,13 @@ GEM ox (2.14.23) bigdecimal (>= 3.0) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.9) + pg (1.6.0) pghero (3.7.0) activerecord (>= 7.1) playwright-ruby-client (1.54.0) @@ -731,7 +731,7 @@ GEM railties (>= 5.2) rexml (3.4.1) rotp (6.3.0) - rouge (4.5.2) + rouge (4.6.0) rpam2 (4.0.2) rqrcode (3.1.0) chunky_png (~> 1.0) @@ -860,7 +860,7 @@ GEM stoplight (4.1.1) redlock (~> 1.0) stringio (3.1.7) - strong_migrations (2.4.0) + strong_migrations (2.5.0) activerecord (>= 7.1) swd (2.0.3) activesupport (>= 3) @@ -868,7 +868,7 @@ GEM faraday (~> 2.0) faraday-follow_redirects sysexits (1.2.0) - temple (0.10.3) + temple (0.10.4) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) terrapin (1.1.1) @@ -1108,4 +1108,4 @@ RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.7.0 + 2.7.1 diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb index 8b8e83fde7..61ca166189 100644 --- a/app/controllers/admin/action_logs_controller.rb +++ b/app/controllers/admin/action_logs_controller.rb @@ -6,7 +6,7 @@ module Admin def index authorize :audit_log, :index? - @auditable_accounts = Account.auditable.select(:id, :username) + @auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc) end private diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb index 0c41553676..7c70603e23 100644 --- a/app/controllers/admin/disputes/appeals_controller.rb +++ b/app/controllers/admin/disputes/appeals_controller.rb @@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController end def reject - authorize @appeal, :approve? + authorize @appeal, :reject? log_action :reject, @appeal @appeal.reject!(current_account) UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index c3443b7077..5e1074b224 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -36,7 +36,7 @@ module Admin end def edit - authorize :domain_block, :create? + authorize :domain_block, :update? end def create @@ -129,7 +129,7 @@ module Admin end def requires_confirmation? - @domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm] + @domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm] end end end diff --git a/app/controllers/admin/username_blocks_controller.rb b/app/controllers/admin/username_blocks_controller.rb new file mode 100644 index 0000000000..22ac940817 --- /dev/null +++ b/app/controllers/admin/username_blocks_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Admin::UsernameBlocksController < Admin::BaseController + before_action :set_username_block, only: [:edit, :update] + + def index + authorize :username_block, :index? + @username_blocks = UsernameBlock.order(username: :asc).page(params[:page]) + @form = Form::UsernameBlockBatch.new + end + + def batch + authorize :username_block, :index? + + @form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected') + rescue Mastodon::NotPermittedError + flash[:alert] = I18n.t('admin.username_blocks.not_permitted') + ensure + redirect_to admin_username_blocks_path + end + + def new + authorize :username_block, :create? + @username_block = UsernameBlock.new(exact: true) + end + + def edit + authorize @username_block, :update? + end + + def create + authorize :username_block, :create? + + @username_block = UsernameBlock.new(resource_params) + + if @username_block.save + log_action :create, @username_block + redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg') + else + render :new + end + end + + def update + authorize @username_block, :update? + + if @username_block.update(resource_params) + log_action :update, @username_block + redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg') + else + render :new + end + end + + private + + def set_username_block + @username_block = UsernameBlock.find(params[:id]) + end + + def form_username_block_batch_params + params + .expect(form_username_block_batch: [username_block_ids: []]) + end + + def resource_params + params + .expect(username_block: [:username, :comparison, :allow_with_approval]) + end + + def action_from_button + 'delete' if params[:delete] + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 633dc43778..222beab9ce 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -66,7 +66,11 @@ class Api::V1::StatusesController < Api::BaseController add_async_refresh_header(async_refresh) elsif !current_account.nil? && @status.should_fetch_replies? add_async_refresh_header(AsyncRefresh.create(refresh_key)) - ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) + + WorkerBatch.new.within do |batch| + batch.connect(refresh_key, threshold: 1.0) + ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id }) + end end render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb index d850e05e94..08b01d6b10 100644 --- a/app/controllers/settings/migration/redirects_controller.rb +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController end def destroy - if current_account.moved_to_account_id.present? + if current_account.moved? current_account.update!(moved_to_account: nil) ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 341b0e6472..af6bebf36f 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -11,6 +11,7 @@ class StatusesController < ApplicationController before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :redirect_to_original, only: :show + before_action :verify_embed_allowed, only: :embed after_action :set_link_headers @@ -40,8 +41,6 @@ class StatusesController < ApplicationController end def embed - return not_found if @status.hidden? || @status.reblog? - expires_in 180, public: true response.headers.delete('X-Frame-Options') @@ -50,6 +49,10 @@ class StatusesController < ApplicationController private + def verify_embed_allowed + not_found if @status.hidden? || @status.reblog? + end + def set_link_headers response.headers['Link'] = LinkHeader.new( [[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]] diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 859f924687..4a55a36ecd 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -13,6 +13,8 @@ module Admin::ActionLogsHelper end when 'UserRole' link_to log.human_identifier, admin_roles_path(log.target_id) + when 'UsernameBlock' + link_to log.human_identifier, edit_admin_username_block_path(log.target_id) when 'Report' link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index dbec0e649b..081af01b77 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -65,12 +65,12 @@ module FormattingHelper end def rss_content_preroll(status) - if status.spoiler_text? - safe_join [ - tag.p { spoiler_with_warning(status) }, - tag.hr, - ] - end + return unless status.spoiler_text? + + safe_join [ + tag.p { spoiler_with_warning(status) }, + tag.hr, + ] end def spoiler_with_warning(status) @@ -81,10 +81,10 @@ module FormattingHelper end def rss_content_postroll(status) - if status.preloadable_poll - tag.p do - poll_option_tags(status) - end + return unless status.preloadable_poll + + tag.p do + poll_option_tags(status) end end diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 078aba456a..675d8b8730 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -134,7 +134,7 @@ module JsonLdHelper patch_for_forwarding!(value, compacted_value) elsif value.is_a?(Array) compacted_value = [compacted_value] unless compacted_value.is_a?(Array) - return if value.size != compacted_value.size + return nil if value.size != compacted_value.size compacted[key] = value.zip(compacted_value).map do |v, vc| if v.is_a?(Hash) && vc.is_a?(Hash) diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index 7e7345dc8c..a2bef6b33c 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -28,24 +28,24 @@ module ThemeHelper end def custom_stylesheet - if active_custom_stylesheet.present? - stylesheet_link_tag( - custom_css_path(active_custom_stylesheet), - host: root_url, - media: :all, - skip_pipeline: true - ) - end + return if active_custom_stylesheet.blank? + + stylesheet_link_tag( + custom_css_path(active_custom_stylesheet), + host: root_url, + media: :all, + skip_pipeline: true + ) end private def active_custom_stylesheet - if cached_custom_css_digest.present? - [:custom, cached_custom_css_digest.to_s.first(8)] - .compact_blank - .join('-') - end + return if cached_custom_css_digest.blank? + + [:custom, cached_custom_css_digest.to_s.first(8)] + .compact_blank + .join('-') end def cached_custom_css_digest diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx index b5e0de4c59..7e840ab955 100644 --- a/app/javascript/mastodon/components/hotkeys/index.tsx +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -40,7 +40,11 @@ type KeyMatcher = ( */ function just(keyName: string): KeyMatcher { return (event) => ({ - isMatch: normalizeKey(event.key) === keyName, + isMatch: + normalizeKey(event.key) === keyName && + !event.altKey && + !event.ctrlKey && + !event.metaKey, priority: hotkeyPriority.singleKey, }); } diff --git a/app/javascript/mastodon/components/learn_more_link.tsx b/app/javascript/mastodon/components/learn_more_link.tsx new file mode 100644 index 0000000000..b5337794c9 --- /dev/null +++ b/app/javascript/mastodon/components/learn_more_link.tsx @@ -0,0 +1,63 @@ +import { useState, useRef, useCallback, useId } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import Overlay from 'react-overlays/Overlay'; + +export const LearnMoreLink: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const accessibilityId = useId(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const handleClick = useCallback(() => { + setOpen(!open); + }, [open, setOpen]); + + return ( + <> + + + + {({ props }) => ( +
+
{children}
+ +
+ +
+
+ )} +
+ + ); +}; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index e1fd7734e9..38d24921c5 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -48,13 +48,13 @@ class TranslateButton extends PureComponent { return (
-
- -
- + +
+ +
); } @@ -138,6 +138,16 @@ class StatusContent extends PureComponent { onCollapsedToggle(collapsed); } + + // Remove quote fallback link from the DOM so it doesn't + // mess with paragraph margins + if (!!status.get('quote')) { + const inlineQuote = node.querySelector('.quote-inline'); + + if (inlineQuote) { + inlineQuote.remove(); + } + } } handleMouseEnter = ({ currentTarget }) => { diff --git a/app/javascript/mastodon/components/status_quoted.tsx b/app/javascript/mastodon/components/status_quoted.tsx index d3d8b58c33..8d43ea1819 100644 --- a/app/javascript/mastodon/components/status_quoted.tsx +++ b/app/javascript/mastodon/components/status_quoted.tsx @@ -3,19 +3,15 @@ import { useEffect, useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; -import { Link } from 'react-router-dom'; import type { Map as ImmutableMap } from 'immutable'; -import ArticleIcon from '@/material-icons/400-24px/article.svg?react'; -import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; -import { Icon } from 'mastodon/components/icon'; +import { LearnMoreLink } from 'mastodon/components/learn_more_link'; import StatusContainer from 'mastodon/containers/status_container'; import type { Status } from 'mastodon/models/status'; import type { RootState } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; -import QuoteIcon from '../../images/quote.svg?react'; import { fetchStatus } from '../actions/statuses'; import { makeGetStatus } from '../selectors'; @@ -31,7 +27,6 @@ const QuoteWrapper: React.FC<{ 'status__quote--error': isError, })} > - {children} ); @@ -45,27 +40,20 @@ const NestedQuoteLink: React.FC<{ accountId ? state.accounts.get(accountId) : undefined, ); - const quoteAuthorName = account?.display_name_html; + const quoteAuthorName = account?.acct; if (!quoteAuthorName) { return null; } - const quoteAuthorElement = ( - - ); - const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`; - return ( - +
- - - +
); }; @@ -112,39 +100,42 @@ export const QuotedStatus: React.FC<{ defaultMessage='Hidden due to one of your filters' /> ); - } else if (quoteState === 'deleted') { - quoteError = ( - - ); - } else if (quoteState === 'unauthorized') { - quoteError = ( - - ); } else if (quoteState === 'pending') { quoteError = ( - + <> + + + +
+ +
+

+ +

+
+ ); - } else if (quoteState === 'rejected' || quoteState === 'revoked') { + } else if ( + !status || + !quotedStatusId || + quoteState === 'deleted' || + quoteState === 'rejected' || + quoteState === 'revoked' || + quoteState === 'unauthorized' + ) { quoteError = ( - ); - } else if (!status || !quotedStatusId) { - quoteError = ( - ); } @@ -168,7 +159,7 @@ export const QuotedStatus: React.FC<{ isQuotedPost id={quotedStatusId} contextType={contextType} - avatarSize={40} + avatarSize={32} > {canRenderChildQuote && ( = ({ statusId, withBorder }) => { +}> = ({ statusId }) => { const refresh = useAppSelector( (state) => state.contexts.refreshing[statusId], ); + const autoRefresh = useAppSelector( + (state) => + !state.contexts.replies[statusId] || + state.contexts.replies[statusId].length === 0, + ); const dispatch = useAppDispatch(); const intl = useIntl(); const [ready, setReady] = useState(false); @@ -42,6 +44,11 @@ export const RefreshController: React.FC<{ dispatch(completeContextRefresh({ statusId })); if (result.async_refresh.result_count > 0) { + if (autoRefresh) { + void dispatch(fetchContext({ statusId })); + return ''; + } + setReady(true); } } else { @@ -60,7 +67,7 @@ export const RefreshController: React.FC<{ return () => { clearTimeout(timeoutId); }; - }, [dispatch, setReady, statusId, refresh]); + }, [dispatch, setReady, statusId, refresh, autoRefresh]); const handleClick = useCallback(() => { setLoading(true); @@ -78,12 +85,7 @@ export const RefreshController: React.FC<{ if (ready && !loading) { return ( -