From 72406a1cd178772b818609b8b0c30581c1f0fefd Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Tue, 24 Feb 2026 15:22:44 +0100 Subject: [PATCH 01/10] Refactor: Introduce admin base action class (#37960) --- app/models/admin/account_action.rb | 56 ++++++------------------- app/models/admin/base_action.rb | 42 +++++++++++++++++++ app/models/admin/status_batch_action.rb | 30 ++++--------- 3 files changed, 63 insertions(+), 65 deletions(-) create mode 100644 app/models/admin/base_action.rb diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb index 3cde3c7f8e..45c5987b8a 100644 --- a/app/models/admin/account_action.rb +++ b/app/models/admin/account_action.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -class Admin::AccountAction - include ActiveModel::Model - include ActiveModel::Attributes - include AccountableConcern - include Authorization - +class Admin::AccountAction < Admin::BaseAction TYPES = %w( none disable @@ -15,49 +10,13 @@ class Admin::AccountAction ).freeze attr_accessor :target_account, - :current_account, - :type, - :text, - :report_id, :warning_preset_id - attr_reader :warning - attribute :include_statuses, :boolean, default: true - attribute :send_email_notification, :boolean, default: true - alias send_email_notification? send_email_notification alias include_statuses? include_statuses - validates :type, :target_account, :current_account, presence: true - validates :type, inclusion: { in: TYPES } - - def save - return false unless valid? - - ApplicationRecord.transaction do - process_action! - process_strike! - process_reports! - end - - process_notification! - process_queue! - - true - end - - def save! - raise ActiveRecord::RecordInvalid, self unless save - end - - def report - @report ||= Report.find(report_id) if report_id.present? - end - - def with_report? - !report.nil? - end + validates :target_account, presence: true class << self def types_for_account(account) @@ -84,6 +43,17 @@ class Admin::AccountAction private def process_action! + ApplicationRecord.transaction do + handle_type! + process_strike! + process_reports! + end + + process_notification! + process_queue! + end + + def handle_type! case type when 'disable' handle_disable! diff --git a/app/models/admin/base_action.rb b/app/models/admin/base_action.rb new file mode 100644 index 0000000000..15c1acae42 --- /dev/null +++ b/app/models/admin/base_action.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Admin::BaseAction + include ActiveModel::Model + include ActiveModel::Attributes + include AccountableConcern + include Authorization + + attr_accessor :current_account, + :type, + :text, + :report_id + + attr_reader :warning + + attribute :send_email_notification, :boolean, default: true + + alias send_email_notification? send_email_notification + + validates :type, :current_account, presence: true + validates :type, inclusion: { in: ->(a) { a.class::TYPES } } + + def save + return false unless valid? + + process_action! + + true + end + + def save! + raise ActiveRecord::RecordInvalid, self unless save + end + + def report + @report ||= Report.find(report_id) if report_id.present? + end + + def with_report? + !report.nil? + end +end diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb index 4d0ee13038..d6dea02ed0 100644 --- a/app/models/admin/status_batch_action.rb +++ b/app/models/admin/status_batch_action.rb @@ -1,20 +1,14 @@ # frozen_string_literal: true -class Admin::StatusBatchAction - include ActiveModel::Model - include ActiveModel::Attributes - include AccountableConcern - include Authorization +class Admin::StatusBatchAction < Admin::BaseAction + TYPES = %w( + delete + mark_as_sensitive + report + remove_from_report + ).freeze - attr_accessor :current_account, :type, - :status_ids, :report_id, - :text - - attribute :send_email_notification, :boolean - - def save! - process_action! - end + attr_accessor :status_ids private @@ -117,14 +111,6 @@ class Admin::StatusBatchAction report.save! end - def report - @report ||= Report.find(report_id) if report_id.present? - end - - def with_report? - !report.nil? - end - def process_notification! return unless warnable? From 919b1e69b84f1f11376f8ff370f5c5fc7dd7ec9c Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 24 Feb 2026 15:29:46 +0100 Subject: [PATCH 02/10] Add collection report modal (#37961) --- .../collections/detail/collection_menu.tsx | 97 ++++++-- .../features/collections/detail/index.tsx | 4 +- .../mastodon/features/report/comment.jsx | 121 ---------- .../mastodon/features/report/comment.tsx | 217 ++++++++++++++++++ .../features/ui/components/modal_root.jsx | 2 + .../ui/components/report_collection_modal.tsx | 173 ++++++++++++++ .../features/ui/util/async-components.js | 5 + app/javascript/mastodon/locales/en.json | 5 + 8 files changed, 476 insertions(+), 148 deletions(-) delete mode 100644 app/javascript/mastodon/features/report/comment.jsx create mode 100644 app/javascript/mastodon/features/report/comment.tsx create mode 100644 app/javascript/mastodon/features/ui/components/report_collection_modal.tsx diff --git a/app/javascript/mastodon/features/collections/detail/collection_menu.tsx b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx index 2f5a577a55..ba17e8b1ec 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_menu.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx @@ -2,11 +2,13 @@ import { useCallback, useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { useAccount } from '@/mastodon/hooks/useAccount'; import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react'; import { openModal } from 'mastodon/actions/modal'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { IconButton } from 'mastodon/components/icon_button'; +import { me } from 'mastodon/initial_state'; import { useAppDispatch } from 'mastodon/store'; import { messages as editorMessages } from '../editor'; @@ -16,10 +18,18 @@ const messages = defineMessages({ id: 'collections.view_collection', defaultMessage: 'View collection', }, + viewOtherCollections: { + id: 'collections.view_other_collections_by_user', + defaultMessage: 'View other collections by this user', + }, delete: { id: 'collections.delete_collection', defaultMessage: 'Delete collection', }, + report: { + id: 'collections.report_collection', + defaultMessage: 'Report this collection', + }, more: { id: 'status.more', defaultMessage: 'More' }, }); @@ -31,9 +41,11 @@ export const CollectionMenu: React.FC<{ const dispatch = useAppDispatch(); const intl = useIntl(); - const { id, name } = collection; + const { id, name, account_id } = collection; + const isOwnCollection = account_id === me; + const ownerAccount = useAccount(account_id); - const handleDeleteClick = useCallback(() => { + const openDeleteConfirmation = useCallback(() => { dispatch( openModal({ modalType: 'CONFIRM_DELETE_COLLECTION', @@ -45,34 +57,69 @@ export const CollectionMenu: React.FC<{ ); }, [dispatch, id, name]); - const menu = useMemo(() => { - const commonItems = [ - { - text: intl.formatMessage(editorMessages.manageAccounts), - to: `/collections/${id}/edit`, - }, - { - text: intl.formatMessage(editorMessages.editDetails), - to: `/collections/${id}/edit/details`, - }, - null, - { - text: intl.formatMessage(messages.delete), - action: handleDeleteClick, - dangerous: true, - }, - ]; + const openReportModal = useCallback(() => { + dispatch( + openModal({ + modalType: 'REPORT_COLLECTION', + modalProps: { + collection, + }, + }), + ); + }, [collection, dispatch]); - if (context === 'list') { - return [ - { text: intl.formatMessage(messages.view), to: `/collections/${id}` }, + const menu = useMemo(() => { + if (isOwnCollection) { + const commonItems = [ + { + text: intl.formatMessage(editorMessages.manageAccounts), + to: `/collections/${id}/edit`, + }, + { + text: intl.formatMessage(editorMessages.editDetails), + to: `/collections/${id}/edit/details`, + }, null, - ...commonItems, + { + text: intl.formatMessage(messages.delete), + action: openDeleteConfirmation, + dangerous: true, + }, + ]; + + if (context === 'list') { + return [ + { text: intl.formatMessage(messages.view), to: `/collections/${id}` }, + null, + ...commonItems, + ]; + } else { + return commonItems; + } + } else if (ownerAccount) { + return [ + { + text: intl.formatMessage(messages.viewOtherCollections), + to: `/@${ownerAccount.acct}/featured`, + }, + null, + { + text: intl.formatMessage(messages.report), + action: openReportModal, + }, ]; } else { - return commonItems; + return []; } - }, [intl, id, handleDeleteClick, context]); + }, [ + isOwnCollection, + intl, + id, + openDeleteConfirmation, + context, + ownerAccount, + openReportModal, + ]); return ( diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index db0838fc31..9ba4813cc8 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -79,7 +79,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ collection, }) => { const intl = useIntl(); - const { name, description, tag } = collection; + const { name, description, tag, account_id } = collection; const dispatch = useAppDispatch(); const handleShare = useCallback(() => { @@ -114,7 +114,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ {description &&

{description}

} diff --git a/app/javascript/mastodon/features/report/comment.jsx b/app/javascript/mastodon/features/report/comment.jsx deleted file mode 100644 index b80c14fcb9..0000000000 --- a/app/javascript/mastodon/features/report/comment.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import PropTypes from 'prop-types'; -import { useCallback, useEffect, useRef } from 'react'; - -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import { createSelector } from '@reduxjs/toolkit'; -import { OrderedSet, List as ImmutableList } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { shallowEqual } from 'react-redux'; - -import Toggle from 'react-toggle'; - -import { fetchAccount } from 'mastodon/actions/accounts'; -import { Button } from 'mastodon/components/button'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -const messages = defineMessages({ - placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' }, -}); - -const selectRepliedToAccountIds = createSelector( - [ - (state) => state.get('statuses'), - (_, statusIds) => statusIds, - ], - (statusesMap, statusIds) => statusIds.map((statusId) => statusesMap.getIn([statusId, 'in_reply_to_account_id'])), - { - resultEqualityCheck: shallowEqual, - } -); - -const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedDomains, onSubmit, onChangeComment, onToggleDomain }) => { - const intl = useIntl(); - - const dispatch = useAppDispatch(); - const loadedRef = useRef(false); - - const handleClick = useCallback(() => onSubmit(), [onSubmit]); - const handleChange = useCallback((e) => onChangeComment(e.target.value), [onChangeComment]); - const handleToggleDomain = useCallback(e => onToggleDomain(e.target.value, e.target.checked), [onToggleDomain]); - - const handleKeyDown = useCallback((e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - handleClick(); - } - }, [handleClick]); - - // Memoize accountIds since we don't want it to trigger `useEffect` on each render - const accountIds = useAppSelector((state) => domain ? selectRepliedToAccountIds(state, statusIds) : ImmutableList()); - - // While we could memoize `availableDomains`, it is pretty inexpensive to recompute - const accountsMap = useAppSelector((state) => state.get('accounts')); - const availableDomains = domain ? OrderedSet([domain]).union(accountIds.map((accountId) => accountsMap.getIn([accountId, 'acct'], '').split('@')[1]).filter(domain => !!domain)) : OrderedSet(); - - useEffect(() => { - if (loadedRef.current) { - return; - } - - loadedRef.current = true; - - // First, pre-select known domains - availableDomains.forEach((domain) => { - onToggleDomain(domain, true); - }); - - // Then, fetch missing replied-to accounts - const unknownAccounts = OrderedSet(accountIds.filter(accountId => accountId && !accountsMap.has(accountId))); - unknownAccounts.forEach((accountId) => { - dispatch(fetchAccount(accountId)); - }); - }); - - return ( - <> -

- -