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 (
-