~cytrogen/masto-fe

c27b82a43763b44b0b2a2929b9cde588260581b4 — Claire 2 years ago f3fca78
Add `forward_to_domains` parameter to `POST /api/v1/reports` (#25866)

M app/controllers/api/v1/reports_controller.rb => app/controllers/api/v1/reports_controller.rb +1 -1
@@ 23,6 23,6 @@ class Api::V1::ReportsController < Api::BaseController
  end

  def report_params
    params.permit(:account_id, :comment, :category, :forward, status_ids: [], rule_ids: [])
    params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], rule_ids: [])
  end
end

M app/javascript/mastodon/features/report/comment.jsx => app/javascript/mastodon/features/report/comment.jsx +102 -68
@@ 1,87 1,121 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useCallback, useEffect, useRef } from 'react';

import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';

import { OrderedSet, List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { shallowEqual } from 'react-redux';
import { createSelector } from 'reselect';

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' },
});

class Comment extends PureComponent {

  static propTypes = {
    onSubmit: PropTypes.func.isRequired,
    comment: PropTypes.string.isRequired,
    onChangeComment: PropTypes.func.isRequired,
    intl: PropTypes.object.isRequired,
    isSubmitting: PropTypes.bool,
    forward: PropTypes.bool,
    isRemote: PropTypes.bool,
    domain: PropTypes.string,
    onChangeForward: PropTypes.func.isRequired,
  };

  handleClick = () => {
    const { onSubmit } = this.props;
    onSubmit();
  };

  handleChange = e => {
    const { onChangeComment } = this.props;
    onChangeComment(e.target.value);
  };

  handleKeyDown = e => {
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)) {
      this.handleClick();
      handleClick();
    }
  };

  handleForwardChange = e => {
    const { onChangeForward } = this.props;
    onChangeForward(e.target.checked);
  };

  render () {
    const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props;

    return (
      <>
        <h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3>

        <textarea
          className='report-dialog-modal__textarea'
          placeholder={intl.formatMessage(messages.placeholder)}
          value={comment}
          onChange={this.handleChange}
          onKeyDown={this.handleKeyDown}
          disabled={isSubmitting}
        />

        {isRemote && (
          <>
            <p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>

            <label className='report-dialog-modal__toggle'>
              <Toggle checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} />
  }, [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 (
    <>
      <h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3>

      <textarea
        className='report-dialog-modal__textarea'
        placeholder={intl.formatMessage(messages.placeholder)}
        value={comment}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        disabled={isSubmitting}
      />

      {isRemote && (
        <>
          <p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>

          { availableDomains.map((domain) => (
            <label className='report-dialog-modal__toggle' key={`toggle-${domain}`}>
              <Toggle checked={selectedDomains.includes(domain)} disabled={isSubmitting} onChange={handleToggleDomain} value={domain} />
              <FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} />
            </label>
          </>
        )}

        <div className='flex-spacer' />
          ))}
        </>
      )}

        <div className='report-dialog-modal__actions'>
          <Button onClick={this.handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button>
        </div>
      </>
    );
  }
      <div className='flex-spacer' />

      <div className='report-dialog-modal__actions'>
        <Button onClick={handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button>
      </div>
    </>
  );
}

export default injectIntl(Comment);
Comment.propTypes = {
  comment: PropTypes.string.isRequired,
  domain: PropTypes.string,
  statusIds: ImmutablePropTypes.list.isRequired,
  isRemote: PropTypes.bool,
  isSubmitting: PropTypes.bool,
  selectedDomains: ImmutablePropTypes.set.isRequired,
  onSubmit: PropTypes.func.isRequired,
  onChangeComment: PropTypes.func.isRequired,
  onToggleDomain: PropTypes.func.isRequired,
};

export default Comment;

M app/javascript/mastodon/features/ui/components/report_modal.jsx => app/javascript/mastodon/features/ui/components/report_modal.jsx +18 -14
@@ 45,25 45,26 @@ class ReportModal extends ImmutablePureComponent {
  state = {
    step: 'category',
    selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []),
    selectedDomains: OrderedSet(),
    comment: '',
    category: null,
    selectedRuleIds: OrderedSet(),
    forward: true,
    isSubmitting: false,
    isSubmitted: false,
  };

  handleSubmit = () => {
    const { dispatch, accountId } = this.props;
    const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state;
    const { selectedStatusIds, selectedDomains, comment, category, selectedRuleIds } = this.state;

    this.setState({ isSubmitting: true });

    dispatch(submitReport({
      account_id: accountId,
      status_ids: selectedStatusIds.toArray(),
      selected_domains: selectedDomains.toArray(),
      comment,
      forward,
      forward: selectedDomains.size > 0,
      category,
      rule_ids: selectedRuleIds.toArray(),
    }, this.handleSuccess, this.handleFail));


@@ 87,13 88,19 @@ class ReportModal extends ImmutablePureComponent {
    }
  };

  handleRuleToggle = (ruleId, checked) => {
    const { selectedRuleIds } = this.state;
  handleDomainToggle = (domain, checked) => {
    if (checked) {
      this.setState((state) => ({ selectedDomains: state.selectedDomains.add(domain) }));
    } else {
      this.setState((state) => ({ selectedDomains: state.selectedDomains.remove(domain) }));
    }
  };

  handleRuleToggle = (ruleId, checked) => {
    if (checked) {
      this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) });
      this.setState((state) => ({ selectedRuleIds: state.selectedRuleIds.add(ruleId) }));
    } else {
      this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) });
      this.setState((state) => ({ selectedRuleIds: state.selectedRuleIds.remove(ruleId) }));
    }
  };



@@ 105,10 112,6 @@ class ReportModal extends ImmutablePureComponent {
    this.setState({ comment });
  };

  handleChangeForward = forward => {
    this.setState({ forward });
  };

  handleNextStep = step => {
    this.setState({ step });
  };


@@ 136,8 139,8 @@ class ReportModal extends ImmutablePureComponent {
      step,
      selectedStatusIds,
      selectedRuleIds,
      selectedDomains,
      comment,
      forward,
      category,
      isSubmitting,
      isSubmitted,


@@ 185,10 188,11 @@ class ReportModal extends ImmutablePureComponent {
          isSubmitting={isSubmitting}
          isRemote={isRemote}
          comment={comment}
          forward={forward}
          domain={domain}
          onChangeComment={this.handleChangeComment}
          onChangeForward={this.handleChangeForward}
          statusIds={selectedStatusIds}
          selectedDomains={selectedDomains}
          onToggleDomain={this.handleDomainToggle}
        />
      );
      break;

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +1 -0
@@ 5784,6 5784,7 @@ a.status-card.compact:hover {
  &__toggle {
    display: flex;
    align-items: center;
    margin-bottom: 10px;

    & > span {
      font-size: 17px;

M app/services/report_service.rb => app/services/report_service.rb +19 -3
@@ 16,7 16,11 @@ class ReportService < BaseService

    create_report!
    notify_staff!
    forward_to_origin! if forward?

    if forward?
      forward_to_origin!
      forward_to_replied_to!
    end

    @report
  end


@@ 29,7 33,7 @@ class ReportService < BaseService
      status_ids: reported_status_ids,
      comment: @comment,
      uri: @options[:uri],
      forwarded: forward?,
      forwarded: forward_to_origin?,
      category: @category,
      rule_ids: @rule_ids
    )


@@ 45,11 49,15 @@ class ReportService < BaseService
  end

  def forward_to_origin!
    return unless forward_to_origin?

    # Send report to the server where the account originates from
    ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, @target_account.inbox_url)
  end

  def forward_to_replied_to!
    # Send report to servers to which the account was replying to, so they also have a chance to act
    inbox_urls = Account.remote.where(id: Status.where(id: reported_status_ids).where.not(in_reply_to_account_id: nil).select(:in_reply_to_account_id)).inboxes - [@target_account.inbox_url]
    inbox_urls = Account.remote.where(domain: forward_to_domains).where(id: Status.where(id: reported_status_ids).where.not(in_reply_to_account_id: nil).select(:in_reply_to_account_id)).inboxes - [@target_account.inbox_url]

    inbox_urls.each do |inbox_url|
      ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)


@@ 60,6 68,14 @@ class ReportService < BaseService
    !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
  end

  def forward_to_origin?
    forward? && forward_to_domains.include?(@target_account.domain)
  end

  def forward_to_domains
    @forward_to_domains ||= (@options[:forward_to_domains] || [@target_account.domain]).filter_map { |domain| TagManager.instance.normalize_domain(domain&.strip) }.uniq
  end

  def reported_status_ids
    return AccountStatusesFilter.new(@target_account, @source_account).results.with_discarded.find(Array(@status_ids)).pluck(:id) if @source_account.local?


M spec/services/report_service_spec.rb => spec/services/report_service_spec.rb +21 -3
@@ 44,9 44,27 @@ RSpec.describe ReportService, type: :service do
          stub_request(:post, 'http://foo.com/inbox').to_return(status: 200)
        end

        it 'sends ActivityPub payload to the author of the replied-to post' do
          subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward)
          expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made
        context 'when forward_to_domains includes both the replied-to domain and the origin domain' do
          it 'sends ActivityPub payload to both the author of the replied-to post and the reported user' do
            subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_account.domain, remote_thread_account.domain])
            expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made
            expect(a_request(:post, 'http://example.com/inbox')).to have_been_made
          end
        end

        context 'when forward_to_domains includes only the replied-to domain' do
          it 'sends ActivityPub payload only to the author of the replied-to post' do
            subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_thread_account.domain])
            expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made
            expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made
          end
        end

        context 'when forward_to_domains does not include the replied-to domain' do
          it 'does not send ActivityPub payload to the author of the replied-to post' do
            subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward)
            expect(a_request(:post, 'http://foo.com/inbox')).to_not have_been_made
          end
        end
      end
    end